Any language
Write a LogNorth client in any language. What to send, when to send it, and how to retry.
LogNorth has SDKs for Go, Node and Bun, and Rails, and it accepts OpenTelemetry logs. For any other language, write a small client. This page is the full protocol. A client that follows it behaves like the official SDKs.
Using a coding agent? Let it write the client.
Let your agent write it
Section titled “Let your agent write it”- Connect your agent to LogNorth with the one command on the MCP page. It installs the LogNorth plugin in Claude Code, Codex, and Gemini CLI.
- In your project, ask your agent to “add LogNorth”. The plugin’s
lognorth-integrateskill does the rest.
The agent detects the language. Where an SDK exists, it installs it. Where none exists, it writes a small client that follows this page, with the buffering and retry rules, and tests it. Then it reads the first event back from LogNorth to prove it arrives.
The request
Section titled “The request”POST {LOGNORTH_URL}/api/v1/events/batchAuthorization: Bearer lgn-...Content-Type: application/json{ "events": [ { "message": "POST /checkout → 500", "timestamp": "2026-10-01T12:00:05.123Z", "duration_ms": 231, "trace_id": "req-8f2c", "context": { "method": "POST", "path": "/checkout", "status": 500, "environment": "production", "error": "card declined", "error_class": "CardError", "error_file": "payments.py", "error_line": 42, "stack_trace": "..." } } ]}- Read the URL and the key from the environment:
LOGNORTH_URLandLOGNORTH_API_KEY. Never put a key in source code. - Each environment is its own app in LogNorth, with its own key (
lgn-). The agent key (lgn-agent-) reads only and cannot send. - LogNorth accepts at most 1,000 events and 4 MB per request. Send smaller batches: at most 500 events and 1 MB.
- You may compress the body: send
Content-Encoding: gzip. LogNorth inflates it up to 16 MB.
One event
Section titled “One event”| Field | Type | Required | Notes |
|---|---|---|---|
message |
string | yes | What happened. Keep it under 1,000 characters. |
timestamp |
string | no | RFC 3339 in UTC, for example 2026-10-01T12:00:05.123Z. Set it when the event happens, not when you send it. Default: the time LogNorth receives it. |
duration_ms |
integer | no | How long it took. |
trace_id |
string | no | Ties together the events of one request or job. Keep it under 100 characters. |
context |
object | no | Everything else. Every key is searchable. |
Message
Section titled “Message”Keep one shape per kind of event, so the log reads the same across apps:
- A request:
GET /cart → 200 - A job:
SendInvoices completedorSendInvoices failed - Anything else: a short sentence,
User signed up
Context keys LogNorth reads
Section titled “Context keys LogNorth reads”| Key | Example | What LogNorth does with it |
|---|---|---|
method |
"POST" |
Shows the request method. |
path |
"/checkout" |
Groups requests into endpoints, for stats and alerts. Send the path without the query string. |
status |
500 |
A number. 500 and above makes the event an error. |
environment |
"production" |
Shows where the event came from. |
error |
"card declined" |
Makes the event an error. Groups errors into issues. |
error_class |
"CardError" |
Makes the event an error. Groups errors into issues. |
error_file, error_line, error_caller |
"payments.py", 42, "charge" |
Points at the code. Agents use it to open the right file. |
stack_trace |
"..." |
Shown on the error. Keep it to about 20 frames. |
job |
"SendInvoices" |
Marks a background job. |
user |
"42" |
Who made the request: an ID, never an email. The issue page counts the users an issue hit. |
release |
"a1b2c3d" |
The version that runs, such as a git SHA. Send it on errors. The issue page shows the release an issue first and last appeared in, and error emails name it. |
user_agent |
"Mozilla/5.0 …" |
Send it on failed requests only, to tell a bot from a browser. |
An event is an error when error or error_class is a non-empty string, or when status is 500 or more. There are no levels. A 404 is not an error: it is the client’s mistake, not yours.
LogNorth drops events whose path starts with /. (scanners probing /.env, /.git). Skip other noise, such as health checks, in the client.
What to capture
Section titled “What to capture”- Every request. Add a middleware that sends one event after the response: method, path, status, and duration. Give it a trace ID and pass that ID to everything else the request logs.
- When the user is signed in, add
user. Let the app name it with one call, such asset_user(id), that stores it with the request’s trace ID. - When the status is 500 or more, add
user_agent.
- When the user is signed in, add
- Every unhandled error. Catch it in the same middleware or in the framework’s error hook. Send
error,error_class, the file and line where it was raised, and the stack trace. Addreleaseand, inside a request,user. - Every background job. One event when it ends, with its duration, and the error fields when it fails.
- A few business events, only where they earn their place: a signup, a payment.
Send in every environment except development and test. Staging and previews report too. Put the environment name in context.environment.
Read the release once, at startup: an option the app passes, or else the first of LOGNORTH_RELEASE, GIT_SHA, GIT_COMMIT, SOURCE_COMMIT, KAMAL_VERSION, RENDER_GIT_COMMIT, HEROKU_SLUG_COMMIT, SOURCE_VERSION, RAILWAY_GIT_COMMIT_SHA, and VERCEL_GIT_COMMIT_SHA that is set. Put it on error events only, so other events stay small. Never overwrite a release the app set itself.
Time and order
Section titled “Time and order”LogNorth shows events by the time they happened, not by the time they arrived. Two rules make that hold:
- Set
timestampthe moment the event happens, in UTC with milliseconds, for example2026-10-01T12:00:05.123Z. For a request, use the time the response was sent. Never set it at send time: a batch can wait in the buffer, or go out again after an outage. A retried event keeps its original timestamp. - Keep the order you logged in. The buffer is first in, first out. A failed batch goes back to the front. One request is in flight at a time. A flush stops at the first failed batch instead of sending the ones behind it.
So a batch that waited out a ten-minute outage shows up in the log at its own time, ten minutes back, not on top. Events with the same timestamp keep the order the client logged them in.
Two things work by arrival instead: following the log live (north tail, the live view) shows events as they arrive, each with its own timestamp, and an event without a timestamp gets the time LogNorth received it. So always send one.
LogNorth does not correct client clocks. Keep the server’s clock in sync (NTP), as for any log.
Buffering
Section titled “Buffering”A logging client must never slow down or break the app it watches, and it must not lose events it can still deliver.
- Never send on the request path. Put the event in a memory buffer and return. A background thread, task, or timer sends.
- Never raise into the app. A failure to send is the client’s problem. Write one line to stderr when the state changes (failing, recovered, dropped), not one line per attempt.
- Trim each event before it enters the buffer, so one huge event cannot use up the memory. At most 64 KB per event:
message: 1,000 characters.context.stack_trace: 16 KB, keeping the start (the top of the stack).- Every other string in
context: 8 KB. - Still over 64 KB: keep only
error,error_class,error_file,error_line,method,path,status,environment,release, anduser. - When anything was trimmed, set
context.truncated = true.
- Limit the buffer by count and by size: 10,000 events or 10 MB of JSON, whichever comes first. Measure each event once, when it enters, and keep a running total. A LogNorth that is down for an hour must not take the app’s memory with it.
- When a limit is reached, drop the oldest event that is not an error. Drop an error only when the buffer holds nothing else. Count what you drop.
- Send when the buffer holds 10 events, when an error arrives, or when the buffer passes half of either limit. Otherwise send 5 seconds after the first event arrived.
- Send one request at a time, with at most 500 events and 1 MB of JSON. That keeps the order, stays well under the server’s limits, and does not flood a server that is struggling.
- Flush on shutdown. On SIGTERM, SIGINT, or process exit, send what is left within a few seconds. Containers stop with SIGTERM, so a client that skips this loses the last events of every deploy.
Retries
Section titled “Retries”The rule: keep the events and send them again, after the right wait. LogNorth says how long to wait.
| Answer | What it means | What the client does |
|---|---|---|
201 |
Stored | Done. The body says {"created": N}; "errors" lists events the server rejected as invalid. Do not resend those. |
503 |
LogNorth could not store the events | Keep the events. Wait the seconds in Retry-After (LogNorth sends 5), then send again. |
429 |
Too many requests | Keep the events. Wait the seconds in Retry-After, the time until the rate window resets, then send again. |
Other 5xx, 408, network error, timeout |
No usable answer | Keep the events. Back off: 1 second, then 2, 4, 8, up to 60 seconds, with up to 20% random jitter. |
401, 403, 404 |
Wrong key or wrong URL | Keep the events. Write one line to stderr. Retry every minute, then less often, up to every 5 minutes, until someone fixes the key or the URL. |
413, 400, other 4xx |
This batch cannot be accepted as it is | Split the batch in half and send each half. A single event that is still refused is dropped and counted: sending it again cannot work. |
Rules for keeping events:
- Put unsent events back at the front of the buffer, in the same order, so the oldest go out first.
- Keep accepting new events while you wait. The memory limits still apply.
- Reset the backoff after a success.
Retry-Afterdoes not grow the backoff. When LogNorth says how long to wait, wait exactly that. The backoff is for answers that do not say.- A retry takes a full batch again, up to 500 events from the front, so a backlog drains fast once LogNorth accepts.
- Use timeouts: 5 seconds to connect and 10 seconds for the whole request. A client without timeouts can wait forever on a dead connection.
- Accept rare duplicates. A retry after a timeout can store the same event twice, because the first request may have succeeded. That is better than losing it.
- Report what you dropped. After the next success, send one event:
LogNorth client dropped N events, withcontext.droppedandcontext.dropped_errors. Then a loss shows up in LogNorth itself, not only in a log file nobody reads.
A reference client
Section titled “A reference client”In pseudocode:
buffer = [] # oldest first; limits: 10,000 events or 10 MBbackoff = 1sdropped = 0, dropped_errors = 0
log(event): if environment in ("development", "test"): return event.timestamp = now_utc() event.context.environment = environment if is_error(event) and release and not event.context.release: event.context.release = release # read once at startup trim(event) # at most 64 KB, context.truncated = true while over_limits(buffer + event): drop the oldest non-error event (an error only if nothing else is left) count it in dropped (and dropped_errors) append(buffer, event) if is_error(event) or len(buffer) >= 10 or buffer past half a limit: wake_sender() else: wake_sender_in(5s)
sender loop (one request at a time, in the background): batch = take_from_front(buffer, at most 500 events and 1 MB) answer = POST /api/v1/events/batch {"events": batch} with timeouts (connect 5s, total 10s) case answer: 2xx: backoff = 1s if dropped: append the "dropped N events" report 503, 429: put_back_front(buffer, batch) if Retry-After: sleep(Retry-After) # backoff unchanged else: sleep(backoff); backoff = min(backoff * 2, 60s) 401, 403, 404: put_back_front(buffer, batch); write_stderr once sleep(1 minute, doubling up to 5 minutes) 413, 400, 4xx: if len(batch) > 1: put both halves back at the front else: count the event in dropped 5xx, 408, none: put_back_front(buffer, batch) sleep(backoff ± 20%); backoff = min(backoff * 2, 60s)
on SIGTERM, SIGINT, exit: send what is left within a few secondsCheck it works
Section titled “Check it works”-
Send one event by hand:
Terminal window curl -i -X POST "$LOGNORTH_URL/api/v1/events/batch" \-H "Authorization: Bearer $LOGNORTH_API_KEY" \-H "Content-Type: application/json" \-d '{"events":[{"message":"GET /hello → 200","context":{"path":"/hello","status":200}}]}'You should get
201 Createdand{"created":1}. -
Run the app with an environment other than
development, and make a request. -
Look at the app in LogNorth, or ask your agent: the
search_logstool withsince: 5mreturns the event. Events appear within seconds.
Checklist
Section titled “Checklist”A client that follows this page:
- Sends to
/api/v1/events/batchwithAuthorization: Bearer, in batches of at most 500 events and 1 MB. - Reads the URL and key from
LOGNORTH_URLandLOGNORTH_API_KEY. - Sets the timestamp when the event happens, in UTC with milliseconds, and keeps it on every retry.
- Keeps the logged order: first in, first out, failed batches back at the front, one request at a time.
- Sends one event per request, per unhandled error, and per finished job.
- Sends nothing in
developmentandtest, and setscontext.environmenteverywhere else. - Puts
useron requests when one is signed in,releaseon errors, anduser_agenton failed requests. - Never sends on the request path and never raises into the app.
- Trims each event to 64 KB and marks it
truncated. - Limits the buffer to 10,000 events or 10 MB, and drops the oldest non-error event first.
- Sends at 10 events, on an error, past half a limit, or after 5 seconds, and flushes on shutdown.
- Keeps the events on
503,429,5xx,401,403,404, network errors, and timeouts, and waitsRetry-Afterwhen LogNorth sends it. - Splits a refused batch, and drops only a single event that is still refused.
- Reports dropped events to LogNorth after the next success.
- Uses a 5-second connect timeout and a 10-second request timeout.