SDK behaviour
What every official Trailscope SDK does, so that a request logged from Express looks the same as one logged from Django. ingest-protocol.md is the wire contract; this page is how the SDKs use it. A new SDK should follow it point by point.
| Package | Language | Integrations |
|---|---|---|
@trailscope/node |
Node.js 18+ | core client, used directly by workers and scripts |
@trailscope/express |
Node.js 18+ | Express 4 and 5 |
@trailscope/fastify |
Node.js 20+ | Fastify 5 |
@trailscope/nestjs |
Node.js 20+ | NestJS 11 and 12 (Express or Fastify platform), Logger bridge, BullMQ |
trailscope |
Python 3.10+ | Django, ASGI (FastAPI, Starlette), Celery, logging |
pro.trailscope:trailscope-java, -servlet, -spring-boot-starter, -logback |
Java 17+ | Spring Boot 3 (Spring MVC), any Jakarta Servlet 6 container, Logback, @Async and executors |
trailscope/trailscope |
PHP 8.2+ | Laravel 12 and 13 (middleware, exception reporting, queues, log channel, Octane), Monolog 3, core client |
1. Never block the request path
Section titled “1. Never block the request path”- Capture calls are synchronous, cheap and never touch the network. They append to an in-memory queue bounded at 10,000 items. When it is full, the oldest item is dropped and counted (
stats().dropped). - A background flush (a timer in Node, a daemon thread in Python and Java; PHP has no threads, so it flushes after the response has been sent, between queue jobs, on Octane ticks and at shutdown, see sdk-laravel.md) runs every second, or early once 500 items are queued. A flush sends batches of up to 1,000 items as gzipped NDJSON to
POST /v1/ingest. - Redaction,
beforeSend, serialization and compression happen in the flush, not when the item is captured. - Retries: network errors,
429and5xxput the batch back at the front of the queue (dropping the oldest items if there is no room) and pause flushing with exponential backoff and jitter (1 s doubling to 60 s, randomized between half and the full value). ARetry-Afterheader sets the minimum pause. Other4xxresponses drop the batch;401/403print one warning about the ingest key. - Shutdown flushes what is left, waiting at most 5 seconds. Node:
client.shutdown()(also onbeforeExit, and byTrailscopeModuleon application shutdown). Python:client.shutdown(), registered withatexitbyinit(). Java:client.shutdown(), a JVM shutdown hook added byTrailscope.init(), or the Spring context closing. PHP: every client registers a shutdown function. - Forks (Python): after
os.fork()the child starts with an empty queue, fresh locks and a new flush thread, so preforking servers (gunicorn, Celery prefork) don’t share or duplicate items. - An SDK must never raise into the application because of Trailscope: transport, serialization and recording errors are caught and reported only through the SDK’s debug log (
TRAILSCOPE_DEBUG=1).
2. Configuration
Section titled “2. Configuration”Each option can be passed in code; the ones below also read an environment variable.
| Option | Env | Default |
|---|---|---|
| endpoint | TRAILSCOPE_ENDPOINT |
http://localhost:8080 |
| ingest key | TRAILSCOPE_INGEST_KEY |
none: nothing is sent |
| service | TRAILSCOPE_SERVICE |
unknown-service |
| environment | TRAILSCOPE_ENVIRONMENT (Node also NODE_ENV) |
production |
| release | TRAILSCOPE_RELEASE |
empty |
| enabled | TRAILSCOPE_ENABLED=0 disables |
on |
| debug log | TRAILSCOPE_DEBUG=1 |
off |
The others (queue size, flush interval and size, sampling, body capture, ignored routes, redaction, beforeSend) are code options, named in the language’s style (sampleRate / sample_rate).
Settings from the server
Section titled “Settings from the server”At startup and then every 5 minutes, the SDK fetches GET /v1/config (the project’s settings, editable in the UI under Settings → Project) and applies sampleRate, routeSampleRates, slowMs, bodyCapture, maxBodyBytes, ignoreRoutes and maskIp. An option the application set in code always wins over the server’s value. A failed fetch keeps the current values. Node skips the fetch when a custom fetch is injected (tests), unless remoteConfig: true is passed.
3. Context
Section titled “3. Context”Every request runs inside a context (AsyncLocalStorage in Node, contextvars in Python, a ThreadLocal in Java carried to other threads by Trailscope.wrap(), a per-process slot in PHP) holding:
| Field | Source |
|---|---|
| request ID | the incoming X-Request-Id (configurable) if it matches [A-Za-z0-9._:-]{1,128}, otherwise 32 random hex characters. Returned in the response’s X-Request-Id header |
| trace ID, parent span ID | the incoming W3C traceparent if valid, otherwise a new trace ID |
| span ID | new, 16 hex characters |
| user ID | setUser() / set_user() (Django: the authenticated user, if the request already loaded it) |
| correlation keys | setCorrelation(key, value): the journey keys (an account, an order, a session…) |
| attributes | setAttribute(key, value) |
| route | the framework’s matched route pattern |
| error | the exception that failed the request |
Log lines, spans, events and jobs started inside the request read the context, so they carry its request ID, trace ID, user and correlation keys.
4. HTTP requests
Section titled “4. HTTP requests”One request item per HTTP exchange, recorded when the response finishes:
- Route is the framework’s pattern converted to
:nameparameters (/projects/:project_id/tasks): Expressreq.baseUrl + req.route.path, FastifyrouteOptions.url, Nest’s matched route, Djangoresolver_match.route(<int:id>and regex groups become:id), Starlette/FastAPIscope["route"].path_format({id}becomes:id) with any mount prefix, Spring MVCHandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE({id}becomes:id) with the context path, Laravel the matched route’s URI ({id}and{id?}become:id). When nothing matched (a 404), the route is left empty and the server derives one from the path. - Ignored routes (default
/health,/healthz,/ready,/readyz,/livez, matched against the route and the path) aren’t recorded. - Sampling: requests with status ≥ 400, an error, or a duration ≥
slowMs(default 1,000 ms) are always kept. Others are kept with probabilitysampleRate(default 1), overridable per route. A kept sampled request records itssampleRateso counts can be scaled. - Bodies follow the capture policy:
errors(the default: status ≥ 400 or slow),alwaysornever. At mostmaxBodyBytes(default 8 KB) per body is kept, cut at a UTF-8 boundary, andbodyTruncatedis set. Binary and multipart bodies (by content type, or a NUL byte in the first 512 bytes) and compressed responses (Content-Encodingother thanidentity) are skipped, as are Django’s streaming responses. For multipart forms, Django keeps the text fields it already parsed and lists files by name, size and type, never their content. Response bodies are copied as they are written, at mostmaxBodyBytes + 1bytes, whatever the response size, so a streamed response keeps its beginning. - Errors: the exception’s type, message and full stack are attached only when the response status is ≥ 500. A handled 4xx (validation, not found, forbidden) is an expected outcome and isn’t grouped as an error.
- A request whose client disconnects before the response is sent is recorded with status 499.
- IP: the framework’s notion of the client IP when it has one (Express
req.ip, Fastifyrequest.ip, which honour their trust-proxy settings), otherwise the firstX-Forwarded-Forhop, then the socket address. - Also recorded: method, path, query, duration (monotonic clock), request and response sizes, user agent, request and response headers.
5. Redaction
Section titled “5. Redaction”On by default, applied in the flush before anything leaves the process. The server applies the project’s own rules again.
- Headers:
authorization,cookie,set-cookie,x-api-key, plus custom names. - Body keys at any depth, case-insensitive, ignoring
_and-:password,passwd,secret,token,access_token,refresh_token,otp,pin,cvv,card,card_number,iban,ssn, plus custom keys and dotted paths (user.profile.dob,*matching any key or array index). Applies to JSON bodies, form bodies and the query string; bodies that aren’t valid JSON (for example truncated ones) get a text-level pass for the same keys. - Patterns: custom regexes, replaced in string values, header values, the query, error messages and log messages.
- IP masking (default on): the last IPv4 octet becomes
0; IPv6 keeps the first 48 bits. - Redacted values become
"[REDACTED]".
6. Jobs
Section titled “6. Jobs”- A job started inside a request belongs to it by default. To carry the context to another process, serialize it into the job (
serializeContext()/serialize_context()/ JavaTrailscope.serializeContext(); the BullMQ helper usesjob.data.__trailscope, the Celery integration atrailscopemessage header, the Laravel integration atrailscopekey in the job payload) and restore it in the worker. - Each run is an
eventwithkind: "job", the job name,statusokorerror, the duration, the 1-based attempt number, the error’s type, message and stack on failure, and the restored request ID, trace ID, user and correlation keys. Celery retries are recorded aserrorevents with levelwarn. - Log lines written during the job carry the job’s context.
7. Logs, events and spans
Section titled “7. Logs, events and spans”- Logs:
log(level, message)and the logger bridges (NestTrailscopeLogger, PythonTrailscopeHandler) sendeventitems withkind: "log", the logger name or context as the event name, and the current context. Bridges skip the SDK’s own logs and, by default, framework startup noise and Nest’sExceptionsHandlerlines (the request already carries that error). - Events:
captureEvent/capture_eventfor custom events (kind: "custom"). - Spans:
span(kind, name, fn)(Node) andwith client.span(kind, name)(Python) time a block as a child of the current request’s span.
8. Overhead
Section titled “8. Overhead”Each SDK ships a benchmark of what it adds to the request path, which must stay under 0.2 ms at p99. Results are in benchmarks.md.