Skip to content

Telemetry

Telemetry is how your App reports on its own health — capturing exceptions, sending log lines, and leaving a breadcrumb trail you can read back when something goes wrong. You reach it through sdk.telemetry, and your handler code is identical no matter which provider is behind it.

The default provider is Sentry. Your App can instead run on Datadog, Better Stack, or OpenTelemetry (OTLP) — that choice is a platform-level setting, not a code change. Whatever is configured, you call the same four methods: captureError, captureMessage, log, and addBreadcrumb. Never import a provider SDK or fetch a provider endpoint directly; go through sdk.telemetry so your code stays portable and the platform can swap providers under you.

Every Telemetry call is fire-and-forget. A missing secret, a provider rate-limit, or a network failure is swallowed — none of them ever surfaces as a handler error. Always call Telemetry with void, never await, so it can never add latency or break your request path.

MethodShapeReturns
captureErrorcaptureError(error, context?)Promise<void>
captureMessagecaptureMessage(message, context?)Promise<void>
loglog(input)Promise<void>
addBreadcrumbaddBreadcrumb(breadcrumb)Promise<void>

You reach these through the SDK inside a handler:

const sdk = c.get("sdk")
void sdk.telemetry.captureError(err)

All four methods return a resolved Promise<void> unconditionally. The void prefix tells TypeScript you are intentionally not awaiting.

captureError is the workhorse. Pass the thrown value as the first argument; the provider records the full stack trace itself.

const sdk = c.get("sdk")
try {
await doWork()
} catch (err) {
void sdk.telemetry.captureError(err)
return c.json({ error: "internal" }, 500)
}

The optional second argument is a context object:

type CaptureErrorContext = {
tags?: Record<string, string>
extra?: Record<string, unknown>
trace?: TraceContext
fingerprint?: string[]
release?: string
user?: TelemetryUser
}
void sdk.telemetry.captureError(err, {
tags: { route: "checkout", region: "ap" },
extra: { orderId },
})

Do not also write error.stack to console in the same catch block. The provider already captures the stack, and echoing it to Worker logs can surface runtime detail you would not otherwise expose.

captureMessage records a notable event that is not an exception — a degraded-but-handled condition, an unexpected branch, anything you want visibility into without throwing.

type CaptureMessageContext = {
level?: TelemetryLevel // "fatal" | "error" | "warning" | "info" | "debug"
tags?: Record<string, string>
extra?: Record<string, unknown>
trace?: TraceContext
fingerprint?: string[]
release?: string
user?: TelemetryUser
}
void sdk.telemetry.captureMessage("stripe webhook arrived out of order", {
level: "warning",
tags: { provider: "stripe" },
})

log sends a single structured log line with a level, a message, and arbitrary attributes.

type LogInput = {
level: TelemetryLevel
message: string // 1–8192 chars
attributes?: Record<string, unknown>
trace?: TraceContext
release?: string
}
void sdk.telemetry.log({
level: "info",
message: "order fulfilled",
attributes: { orderId, itemCount },
})

message is validated to between 1 and 8192 characters. An out-of-range message is silently dropped — the SDK does not throw; it tracks the validation failure internally and your handler continues.

Breadcrumbs are a request-scoped trail. You drop them along the way; if an error is later captured in the same handler invocation, the breadcrumbs ride along with it so you can see what led up to the failure.

type Breadcrumb = {
category: string // 1–200 chars
message: string // 1–2000 chars
level?: TelemetryLevel
data?: Record<string, unknown>
}
const sdk = c.get("sdk")
void sdk.telemetry.addBreadcrumb({
category: "db",
message: "loaded cart",
data: { itemCount },
})
// ... later in the same request ...
void sdk.telemetry.captureError(err) // breadcrumbs attach automatically

The breadcrumb buffer is scoped to one request, holds up to 50 entries (the oldest drop when it fills), and drains into the next captureError or captureMessage. Breadcrumbs are not attached to log or addBreadcrumb calls themselves.

When a request arrives carrying a trace header from an upstream service, parse it and pass the resulting TraceContext to your Telemetry calls. This correlates your App’s errors with the wider request in the provider’s trace view.

type TraceContext = {
traceId: string // 32 hex chars
spanId: string // 16 hex chars
sampled: boolean
}

Two inbound parsers and matching formatters are exported from @anyknown/app-server:

import {
parseTraceparent,
parseSentryTrace,
formatTraceparent,
formatSentryTrace,
newSpanId,
} from "@anyknown/app-server"
const trace = parseTraceparent(c.req.header("traceparent") ?? null)
if (trace) {
void sdk.telemetry.captureError(err, { trace })
}

parseTraceparent reads the W3C traceparent header; parseSentryTrace reads a sentry-trace header. Both return null on absent or malformed input, so the if (trace) guard above is all the validation you need. Use formatTraceparent / formatSentryTrace (and newSpanId for a fresh child span) when you propagate the trace onward to a downstream service.

When you pass a trace with sampled: true, the event bypasses the sample-rate filter and is always sent — the App honors the upstream’s sampling decision.

To attribute an error or message to the Visitor who hit it, use the attachSessionUser helper. It reads the session through the SDK and returns a ready-to-spread user object, so your Telemetry code never reaches into auth internals.

import { attachSessionUser } from "@anyknown/app-server"
const { user } = await attachSessionUser(sdk, c.req.raw)
void sdk.telemetry.captureError(err, { user })
type TelemetryUser = {
id?: string
email?: string
ip_address?: string
}

The helper populates user.id only. The id is sent as-is — it is not hashed — so this is appropriate for an opaque session id. Do not hand-fill email or ip_address unless you have confirmed your provider’s data handling is acceptable for that data; those fields exist for providers that expect them, not as a recommendation to send PII.

fingerprint is an array of strings that tells the provider how to group related errors into a single issue. It is honored only by the Sentry adapter for server-side grouping; the other providers store it as a plain tag or attribute.

void sdk.telemetry.captureError(err, {
fingerprint: ["checkout", "payment-declined"],
})

There is nothing to enable — all four methods and all four providers are available on every App, with no plan flag. The only setup is the secret your active provider needs. You set these by their NAME through Secret Storage; the value is injected for you at runtime and never appears in your code.

Secret nameProviderNotes
SENTRY_DSNSentry (default)Full DSN URL: https://<key>@<host>/<project_id>.
DD_API_KEYDatadog
BETTERSTACK_SOURCE_TOKENBetter Stack
OTEL_EXPORTER_OTLP_HEADERS_AUTHOTLPOptional; sent as the Authorization header if set.

Which provider is active, and a few routing details, are platform settings rather than secrets — they are configured for your App, not set in code:

SettingApplies toNotes
TELEMETRY_PROVIDERSelects the active provider. Absent or unrecognized → Sentry.
TELEMETRY_SAMPLE_RATEFloat 0–1. Absent → 1 (send everything).
TELEMETRY_LOG_DRAIN_URLOptional secondary HTTP endpoint that also receives every event as a JSON POST.
APP_RELEASEDatadog / Better Stack / OTLPDefault release tag when you don’t pass context.release.
DD_SITEDatadogDefaults to datadoghq.com; use datadoghq.eu for EU.
BETTERSTACK_INGEST_HOSTBetter StackRequired for Better Stack — the ingest host, no trailing slash.
OTEL_EXPORTER_OTLP_ENDPOINTOTLPRequired for OTLP — the collector base URL.

For Datadog, Better Stack, and OTLP, if you don’t pass context.release (or LogInput.release), the APP_RELEASE tag is used as the default. For Sentry, release is caller-supplied only.

Under normal SDK wiring, none of the four methods ever rejects — so you almost never need to handle a TelemetryError. It exists for the rare case where you opt in with an explicit .catch(), or for adapter authors.

type TelemetryErrorKind =
| "secret-missing"
| "validation"
| "auth"
| "rate-limited"
| "provider-error"
class TelemetryError extends Error {
readonly kind: TelemetryErrorKind
readonly status: number
readonly retryAfterSec?: number
}

Because every public method already absorbs failures, treat TelemetryError as informational. Do not build your handler’s control flow around catching it.

These are the details that most often trip people up.

  • Always void, never await. Telemetry is fire-and-forget by design — awaiting it only adds latency, because failures are already absorbed and there is no result to act on. Keep it off your request’s critical path.

  • PII is scrubbed on the way out. The extra fields on a capture context, the attributes on a log, and user.email all pass through a configurable PII scrub before leaving the Worker. Pattern-matched values are redacted. This is a safety net, not a license to send personal data on purpose.

  • Sampling can drop events. When TELEMETRY_SAMPLE_RATE is below 1, a fraction of events is dropped before sending. A trace with sampled: true always bypasses sampling, so traced errors are never sampled out.

  • A rate-limited provider trips a brief circuit breaker. If the provider returns 429, further events are dropped silently for about 60 seconds, then sending resumes. The breaker fails open — a fault in the platform’s own limiter never blocks your telemetry.

  • Validation failures no-op silently. An over-long log message, or a breadcrumb that breaks the category/message length limits, is dropped without throwing. Your handler is never interrupted by a malformed Telemetry call — but the event also never arrives, so keep messages within the documented limits.

  • fingerprint is Sentry-only for grouping. On Datadog, Better Stack, and OTLP it is recorded as a plain attribute, not used to merge issues.

  • OTLP sends to two signals when traced. With a trace present, the OTLP adapter emits to both the logs and traces endpoints; without one, only logs. You don’t configure this — it follows from whether you pass trace.

  • The log drain mirrors every sent event. If TELEMETRY_LOG_DRAIN_URL is configured, each event that passes sampling and the circuit breaker is also POSTed there as JSON — independently of whether the primary provider call succeeds.