@strada.sh/sdk in every runtime. The package uses export conditions so browsers get the browser runtime, Cloudflare Workers get the Workers runtime, and servers get the Node runtime.@strada.sh/sdk, not from @opentelemetry/* packages directly. The SDK is a thin wrapper around those packages and re-exports the same APIs, so trace.getTracer(), logs.getLogger(), metrics.getMeter(), context, and propagation work the same way while staying connected to Strada's configured providers.import { initStrada, captureException } from "@strada.sh/sdk" initStrada({ service: "api", projectId: "01JTHG5M7XPQR8KNCZ0W4D", // TODO: replace with your project id, get one with `strada projects create` token: process.env.STRADA_TOKEN, // Server-side only. Omit this in browser apps. environment: "production", version: "1.0.0", enabled: !import.meta.hot, }) // Set enabled: false to keep OTel APIs local without sending data to ingest. // In Vite/RSC dev servers, import.meta.hot is truthy during HMR. try { throw new Error("db timeout") } catch (error) { captureException(error, { tags: { route: "/checkout" }, }) }
enabled is the single kill switch for everything the SDK sends: product events, errors, logs, spans, and metrics. Set it once at init.initStrada({ projectId: process.env.STRADA_PROJECT_ID, token: process.env.STRADA_TOKEN, service: "api", enabled: process.env.NODE_ENV === "production", })
initStrada() still installs the OTel providers, just without an exporter. Every API keeps working and quietly drops its data:track("checkout_started", { plan: "pro" }) // no-op, no network call captureException(error) // no-op getLogger("api").info({ message: "hi" }) // no-op await startSpan({ name: "work" }, doWork) // still runs doWork, span is dropped
initStrada() to disable it. Skipping init makes every call print called before initStrada(). Pass enabled and leave your call sites clean.projectId also disables export, with one warning at startup. Reading the id from an env var or platform secret that may be missing is therefore safe:// Missing secret in local dev or a half configured deploy: no crash, no // requests to a garbage endpoint, no per-call guards. initStrada({ projectId: env.STRADA_PROJECT_ID, // "" while the secret is not set token: env.STRADA_TOKEN, service: "worker", })
endpoint (a local collector, for example) keeps export on even without a projectId.const error = captureException(err) if (error) { // optional. The SDK already logged it once. }
| Function | Returns |
captureException() | Error | undefined |
track() | Error | undefined |
trackPageview() | Error | undefined |
identifyUser() | Error | undefined |
initStrada() | Error | undefined |
flush() / shutdown() | Promise<Error | undefined> |
console.warn, deduplicated by message so a broken exporter cannot flood the console on every request.captureException() is routinely handed whatever a catch block received:const circular = { code: 500 } circular.self = circular captureException(circular) // JSON.stringify throws internally, still safe captureException(new Proxy({}, { get() { throw new Error("nope") } })) captureException(10n) // BigInt is not JSON-serializable
beforeSend hook that throws does not lose the report either: the original error is sent and the hook failure is logged.startSpan(). It wraps your callback, so it records the error on the span and then re-throws it. Your own control flow is unchanged:await startSpan({ name: "checkout" }, async () => { throw new PaymentError() // recorded on the span, then rethrown to you })
getLogger().info() and friends) stay void because StradaLogger extends the OTel Logger interface, but they never throw either.setTags, errorToAttributes, recordExceptionOnSpan, startPageSpan) and the SDK's OTel span/log processors are not wrapped yet, so a hostile getter reached through those can still surface.initStrada(), the global OTel providers are configured. Use the standard OTel APIs re-exported by @strada.sh/sdk.import { initStrada, startSpan, logs, metrics, SeverityNumber } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", token: process.env.STRADA_TOKEN, service: "worker", }) const logger = logs.getLogger("jobs") const meter = metrics.getMeter("jobs") const counter = meter.createCounter("emails.sent") await startSpan({ name: "send-email" }, async (span) => { logger.emit({ body: "sending email", severityText: "INFO", severityNumber: SeverityNumber.INFO, }) await sendEmail() counter.add(1) })
vite build and injects safe public metadata into the bundle.// vite.config.ts import { defineConfig } from "vite" import { stradaVitePlugin } from "@strada.sh/sdk/vite" export default defineConfig({ plugins: [stradaVitePlugin()], })
token in browser apps. Browser ingest is anonymous and rate limited because any browser token would be public.import { initStrada } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "frontend", environment: "production", })
deployment.id so every deployment still has a stable identifier.| Metadata | Platform env vars | Standard OTel resource attribute |
| Release/version | STRADA_RELEASE_VERSION, STRADA_RELEASE, SENTRY_RELEASE, npm_package_version | service.version |
| Commit SHA | VERCEL_GIT_COMMIT_SHA, RENDER_GIT_COMMIT, CF_PAGES_COMMIT_SHA, WORKERS_CI_COMMIT_SHA, GITHUB_SHA | vcs.ref.head.revision |
| Branch/ref | VERCEL_GIT_COMMIT_REF, RENDER_GIT_BRANCH, CF_PAGES_BRANCH, WORKERS_CI_BRANCH, GITHUB_HEAD_REF, GITHUB_REF_NAME | vcs.ref.head.name |
| Deployment id | VERCEL_DEPLOYMENT_ID, WORKERS_CI_BUILD_UUID, RENDER_INSTANCE_ID, FLY_MACHINE_VERSION, GITHUB_RUN_ID, otherwise commit SHA | deployment.id |
GITHUB_SHA, GITHUB_HEAD_REF, GITHUB_REF_NAME, and GITHUB_RUN_ID automatically. For pull request workflows, GITHUB_SHA is usually the synthetic merge commit. If you want the PR head commit instead, pass it explicitly:env: STRADA_RELEASE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
stradaVitePlugin({ version: "frontend@1.4.2", releaseCommit: "9f3a12b0c45d...", releaseBranch: "main", deploymentId: "dpl_123", })
ResourceAttributes for raw logs and traces. Error extraction maps service.version to the denormalized Release column, while commit and deployment metadata stay queryable through resource attributes.userId to initStrada() when the current user is already known. The browser SDK injects it into spans/logs/errors as user.id and propagates it to backend SDKs through W3C Baggage.import { initStrada } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "frontend", userId: () => window.__APP_USER__?.id, })
identifyUser() in the browser. Browser calls persist user.id in cookie strada_uid and update in-memory context. Cookie strada_vid is the visitor and is left alone. identifyUser() does not end or restart the current pageview. They do not store email/name/profile data.import { identifyUser } from "@strada.sh/sdk/browser" identifyUser({ id: user.id }) identifyUser(null) // logout: clear strada_uid, keep strada_vid
identifyUser() from trusted server code to replace the latest profile snapshot in otel_users. This still uses OTLP logs: the SDK emits event.name = "strada.user.identify", the collector stores the raw event in otel_logs, and extracts the latest profile row into otel_users for SQL joins.import { identifyUser } from "@strada.sh/sdk" identifyUser({ id: user.id, email: user.email, name: user.name, image: user.image, organizationId: account.id, organizationName: account.name, })
user.id is stored in cookies. Email, image, name, and organization fields are explicit server-side profile data so PII is not copied into every telemetry row.identifyUser() call is a full snapshot for that user. Pass all profile fields you want to keep, because a later call with only { id, image } intentionally replaces missing fields with empty values in the latest otel_users row.fetch, XMLHttpRequest, http, express, database clients, or console.*.initStrada() only wires OTel providers, exporters, context propagation, and a small set of Strada-owned lifecycle hooks.| Runtime | Automatic traces | Automatic logs |
| Browser | A pageview span starts on initStrada(), restarts on SPA navigation, and ends when the tab is hidden | Uncaught window.error and unhandledrejection events are sent as exception logs |
| Node | No spans are created automatically | uncaughtException and unhandledRejection are sent as exception logs |
| Cloudflare Workers | No spans are created automatically | No process/global error handlers. Only explicit SDK calls send logs |
startSpan() for spans, logs.getLogger().emit() for logs, track() for custom event logs, and captureException() for handled error logs.initStrada() ├─ browser only: span "pageview" ─────────► /v1/traces ├─ browser/node error handlers ───────────► /v1/logs └─ manual OTel / Strada helper calls ─────► /v1/traces or /v1/logs
startSpan after initStrada(). It creates a span, sets it as active in the current context, and auto-ends it when the callback finishes. If the callback throws, the span is marked as ERROR and the exception is recorded automatically.import { initStrada, startSpan } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "api", }) await startSpan({ name: "checkout.request" }, async (span) => { span.setAttribute("checkout.id", "chk_123") span.setAttribute("user.id", "user_123") span.addEvent("payment.started") span.setAttribute("checkout.step", "payment") await processPayment() // span auto-ends here. If processPayment() throws, the span gets // ERROR status and the exception is recorded automatically. })
startSpan calls automatically creates parent-child relationships. The outer span becomes the parent. No manual context wiring needed.import { initStrada, startSpan } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "api", }) await startSpan({ name: "checkout.request" }, async () => { // This span is automatically a child of checkout.request await startSpan({ name: "db.insert-order" }, async (span) => { span.setAttribute("db.system", "postgresql") span.setAttribute("db.operation", "INSERT") await insertOrder() }) })
startSpan(), trace.getTracer('my-app'), and auto-instrumentation libraries all end up in the same trace tree as long as they share the same context.await boundaries) only works on Node.js and Cloudflare Workers, where AsyncLocalStorage preserves context across async operations. In browsers, the OTel StackContextManager loses context after await. Synchronous nesting works correctly everywhere. This is a known limitation of the OTel browser SDK, not specific to Strada.startSpan vs startInactiveSpanstartSpan by default. It creates a span, sets it as active in context, auto-ends it, and auto-records errors. Any spans created inside the callback are automatically parented.startInactiveSpan only creates a span. It does not set it as active and does not auto-end. You must call span.end() yourself. Use this for work that should not parent subsequent spans.startSpan, everything created inside the callback becomes a child. With startInactiveSpan, the context is unchanged, so subsequent spans stay siblings of the inactive span rather than children.startSpan produces a nested tree startInactiveSpan produces flat siblings process-order ████████████████████ process-order ████████████████████ ├─ validate ████████ ├─ send-email ██████████████ └─ charge ████████████ ├─ send-webhook █████████████ └─ log-audit █████
startSpanimport { startSpan } from "@strada.sh/sdk" // HTTP request handler: the request span parents all sub-operations await startSpan({ name: "POST /checkout" }, async () => { await startSpan({ name: "validate-cart" }, async () => { await validateCart(cartId) }) await startSpan({ name: "charge-payment" }, async (span) => { span.setAttribute("payment.provider", "stripe") await chargeCard(paymentMethod) }) await startSpan({ name: "send-confirmation" }, async () => { await sendEmail(userId) }) })
startInactiveSpanimport { startSpan, startInactiveSpan } from "@strada.sh/sdk" await startSpan({ name: "process-order" }, async () => { // These run in parallel and don't parent each other or subsequent work const emailSpan = startInactiveSpan({ name: "send-email" }) const webhookSpan = startInactiveSpan({ name: "notify-webhook" }) await Promise.all([ sendEmail(order).finally(() => emailSpan.end()), notifyWebhook(order).finally(() => webhookSpan.end()), ]) })
// Enqueue a job: the span covers the enqueue call, not the job itself const span = startInactiveSpan({ name: "enqueue-report-generation" }) span.setAttribute("job.type", "monthly-report") await queue.add("generate-report", { month: "2025-01" }) span.end()
using with inactive spansstartInactiveSpan returns a span that implements Symbol.dispose, so you can use JavaScript's using declaration to auto-end it when the block exits. No manual .end() call needed.import { startInactiveSpan } from "@strada.sh/sdk" { using span = startInactiveSpan({ name: "background-cleanup" }) span.setAttribute("queue", "jobs") await cleanupStaleJobs() } // span.end() called automatically here
import { startInactiveSpan, SpanStatusCode } from "@strada.sh/sdk" { using span = startInactiveSpan({ name: "risky-operation" }) try { await doRiskyWork() } catch (err) { // record the error on the span before it auto-ends span.recordException(err instanceof Error ? err : new Error(String(err))) span.setStatus({ code: SpanStatusCode.ERROR }) throw err } } // span.end() called automatically, even after the throw
using spans are not active in context. Child spans created inside the block are not automatically parented to them. Use startSpan (callback form) when you need parent-child nesting. Use using + startInactiveSpan when you want auto-cleanup for a detached span without a callback wrapper.trace.getTracer().startActiveSpan() API is still available. startSpan is sugar on top of it.import { trace, SpanStatusCode } from "@strada.sh/sdk" const tracer = trace.getTracer("checkout") await tracer.startActiveSpan("process-order", async (span) => { try { await processOrder() span.setStatus({ code: SpanStatusCode.OK }) } catch (error) { span.recordException(error instanceof Error ? error : new Error(String(error))) span.setStatus({ code: SpanStatusCode.ERROR }) throw error } finally { span.end() } })
getLogger(). It returns a superset of the standard OTel logger: raw .emit() plus console-style methods that send OTel log records to Strada.import { initStrada, getLogger } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "api", }) const logger = getLogger("checkout") logger.debug("cache miss", "user:123") logger.info("checkout started") logger.warn("slow query", { durationMs: 928 }) logger.error("payment failed", { reason: "card_declined" })
logger.error() creates an error-severity log in otel_logs, but it does not add exception.* attributes and does not create an issue in otel_errors. Use captureException(error) for issue tracking.LogAttributes, and message becomes the log body when it is a string:logger.info({ message: "checkout started", checkoutId: "chk_123", "user.id": "user_123", plan: "pro", retry: false, })
otel_logs like:{ Body: "checkout started", SeverityText: "INFO", LogAttributes: { message: "checkout started", checkoutId: "chk_123", "user.id": "user_123", plan: "pro", retry: "false", }, }
console.*: the arguments are formatted into the body and no structured attributes are added.logger.info("checkout started", { checkoutId: "chk_123" }) // Body = 'checkout started {"checkoutId":"chk_123"}' // LogAttributes = {}
.emit() when you need full control:logger.emit({ body: "payment authorized", severityText: "INFO", severityNumber: SeverityNumber.INFO, attributes: { checkoutId: "chk_123", }, })
console.log() and other console methods are still not patched or sent by default. Only calls to getLogger().info(), getLogger().emit(), logs.getLogger().emit(), track(), captureException(), and automatic runtime handlers listed below send log records.logs.getLogger().emit(). In most cases, severityNumber is enough. severityText is optional.console.log() and other console methods are not sent by default. The browser SDK exports logs you emit through getLogger(), the OTel logs API, track(), captureException(), and uncaught browser errors. It does not monkey-patch console.log, console.info, console.warn, or console.error.import { initStrada, logs, SeverityNumber } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "frontend", }) const logger = logs.getLogger("app") logger.emit({ severityNumber: SeverityNumber.INFO, body: "checkout started", attributes: { "event.name": "checkout_started", "user.id": "user_123", "custom.plan": "pro", }, })
import { initStrada, logs, SeverityNumber } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "api", }) const logger = logs.getLogger("app") try { throw new TypeError("payment failed") } catch (error) { const err = error instanceof Error ? error : new Error(String(error)) logger.emit({ severityNumber: SeverityNumber.ERROR, body: err.message, attributes: { "exception.type": err.name, "exception.message": err.message, "exception.stacktrace": err.stack ?? "", }, }) }
track():import { initStrada, track } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "frontend", }) track("checkout_started", { plan: "pro", source: "pricing-page", })
captureException() when you want Strada's error conventions:import { initStrada, captureException } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "api", }) try { throw new Error("payment failed") } catch (error) { captureException(error, { handled: true, mechanism: "generic", }) }
strada_uid. Unique visitors use a separate cookie strada_vid. Set strada_uid when the user logs in, or call identifyUser({ id }).initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "frontend", }) identifyUser({ id: user.id }) identifyUser(null) // logout: clear strada_uid, keep strada_vid
httpOnly) so the browser SDK can access it via document.cookie.strada_uid from your backend after login if you already have auth middleware. This keeps the user ID available before browser code runs. Any auth library works, the SDK only reads the cookie:app.use(async (req, res, next) => { const user = await getUserFromRequest(req) if (user) { res.setHeader("Set-Cookie", `strada_uid=${encodeURIComponent(user.id)}; Path=/; SameSite=Lax; Secure; Max-Age=31536000`) } next() })
strada_uid cookie and tracks auth lifecycle events. It does not touch strada_vid.import { betterAuth } from "better-auth/minimal" import { strataBetterAuth } from "@strada.sh/sdk/better-auth" export const auth = betterAuth({ plugins: [ strataBetterAuth(), ], })
auth.signup, auth.login, and auth.logout events with user.id, custom.user_email, custom.auth_provider, custom.auth_method, and custom.auth_path attributes. Disable email/name attributes with strataBetterAuth({ includeUserDetails: false }).user.id is injected into every span, every log record, every captureException() and every track() event inside that request. You never pass the id at the call site.import { context, propagation } from "@strada.sh/sdk" app.use(async (req, res, next) => { const user = await getUserFromRequest(req) // session cookie, JWT, API key, anything if (!user) return next() res.setHeader("Set-Cookie", `strada_uid=${encodeURIComponent(user.id)}; Path=/; SameSite=Lax; Secure; Max-Age=31536000`) const baggage = propagation.createBaggage({ "user.id": { value: user.id } }) const ctx = propagation.setBaggage(context.active(), baggage) return context.with(ctx, next) })
user.id over W3C Baggage headers. The middleware above is for server-first requests, plugin backends, APIs, and CLIs, where no browser sent the header.user.id can be joined to an email later. Without Better Auth, call identifyUser() yourself wherever a session is created:import { identifyUser } from "@strada.sh/sdk" identifyUser({ id: user.id, email: user.email, name: user.name, organizationId: org.id, })
otel_users. Telemetry rows keep carrying only user.id, so no PII is copied into every span and log.import { context, propagation } from "@strada.sh/sdk" export function withUser<T>(userId: string, fn: () => T): T { const baggage = propagation.createBaggage({ "user.id": { value: userId } }) return context.with(propagation.setBaggage(context.active(), baggage), fn) } // anywhere a user becomes known return withUser(session.userId, async () => { await handleRequest(req) })
AsyncLocalStorage based context manager, so context.with() survives await:export default { async fetch(request: Request) { const session = await validateSession(request) if (!session) return new Response("unauthorized", { status: 401 }) return withUser(session.userId, () => handle(request, session)) }, }
withUser() too, or pass the identifier explicitly:captureException(error, { tags: { user_id: job.userId, job: job.type } }) track("export.finished", { user_id: job.userId, files: 12 })
custom.user_id, baggage lands as user.id. Pick one convention per project so queries stay simple.otel_traces. No setup needed beyond initStrada().import { initStrada } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "frontend", }) // A pageview span starts immediately and is sent to otel_traces.
initStrada(), a span named "pageview" starts for the current URLsession.id, visitor.id, url.path, url.query, url.full, user.id injected automaticallyvisibilitychange: hidden), the current span ends and flushes{ "name": "pageview", "attributes": { "session.id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "visitor.id": "c0a80123-58cc-4372-a567-0e02b2c3d479", "url.path": "/pricing", "url.query": "?plan=pro", "url.full": "https://app.example.com/pricing", "http.request.header.referer": "https://google.com", "user.id": "user_123" } }
navigation.type ("push", "replace", "traverse") and navigation.user_initiated attributes.otel_analytics_pages, otel_analytics_sessions) for fast dashboard queries (top pages, browsers, countries, bounce rate, session duration). See Browser Analytics for the full schema and queries.track() emits a product analytics event. It works in all three runtimes: browser, Node, and Workers.import { initStrada, track } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "frontend", }) track("signup_started", { plan: "pro", source: "hero", })
otel_logs using event.name.custom. so they don't collide with standard OTel attributes:track("purchase", { plan: "pro", // stored as custom.plan amount: 49, // stored as custom.amount })
TraceId/SpanId, and context attributes (session.id, visitor.id, url.path, user.id) are injected for you. On the server there is no pageview span, so pass the identifiers you want to filter by as properties (user_id, org_id, ...). Use the same key names on both sides so one query covers them.track() on the server at mutation success (project created, deploy finished, subscription started). Those events are the ones you trust, because they cannot fire from a click that later failed. Browser track() is for intent and UI interaction (button clicked, form started). Pageviews need no call at all, they are automatic from initStrada().track() takes a plain string, so track("signup_startedd", { plann: "pro" }) compiles. The typo reaches ClickHouse and you only notice when a dashboard query returns zero rows. Event names and properties are effectively a schema, so declare them in one place and wrap track() once:// analytics-events.ts export type AnalyticsEvents = { "signup_started": { plan: string; source: string } "purchase": { plan: string; amount: number } } export function trackEvent<Name extends keyof AnalyticsEvents>( name: Name, properties: AnalyticsEvents[Name], ) { track(name, properties) }
trackEvent("purchase", { plan: "pro", amount: 49 }) // ok trackEvent("purchse", { plan: "pro", amount: 49 }) // error: unknown event trackEvent("purchase", { plan: "pro" }) // error: missing amount
string | number | boolean). Nested objects and arrays are rejected by track(), which is what you want: custom.* attributes map to single ClickHouse columns, so a nested object would be silently useless in SQL.import type. A single runtime export (a const, an array, a helper) pulls that module, and whatever it imports, into the Worker bundle:import type { AnalyticsEvents } from "shared/analytics-events"
trackEvent wrapper over its own track(), typed by the shared catalog. Event names stay identical across services, so one query covers all of them:SELECT ServiceName, LogAttributes['custom.plan'] AS plan, count() AS n FROM otel_logs WHERE Timestamp >= now() - INTERVAL 7 DAY AND LogAttributes['event.name'] = 'purchase' GROUP BY ServiceName, plan ORDER BY n DESC LIMIT 50
browser / server code │ ├─ traces ───────────────► /v1/traces ├─ logs ───────────────► /v1/logs └─ metrics ───────────────► /v1/metrics │ ▼ Strada collector │ ┌─────────────────┴─────────────────┐ ▼ ▼ otel_traces otel_logs
session.id from sessionStoragevisitor.id from cookie strada_vidurl.path, url.query, url.fullhttp.request.header.refereruser.id from cookie strada_uid or StradaOptions.userIdpageview span and usually parents later browser work to that pageview when no other span is active.track("signup_started") becomes a log record like this:{ "body": "signup_started", "eventName": "signup_started", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "spanId": "00f067aa0ba902b7", "attributes": { "event.name": "signup_started", "session.id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "visitor.id": "c0a80123-58cc-4372-a567-0e02b2c3d479", "url.path": "/pricing", "url.full": "https://app.example.com/pricing", "user.id": "user_123", "custom.plan": "pro", "custom.source": "hero" }, "resource": { "service.name": "frontend", "service.version": "1.0.0" } }
trace: pageview /pricing ├─ span: pageview ├─ span: load-pricing-plans └─ log: signup_started
session.id is the stable browser session identifier. It is not one giant tab-wide TraceId.captureException(error) emits a log record with OTel exception fields.{ "body": "payment failed", "eventName": "exception", "severityText": "ERROR", "attributes": { "exception.type": "Error", "exception.message": "payment failed", "exception.stacktrace": "Error: payment failed...", "exception.mechanism.type": "generic", "exception.mechanism.handled": "true", "session.id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "user.id": "user_123" } }
otel_errors table for grouping and issue views.captureException(), trace.getTracer(), or logs.getLogger(). If none of these are called, zero HTTP requests are made to the collector.import { env } from "cloudflare:workers" import { initStrada, captureException } from "@strada.sh/sdk" initStrada({ projectId: env.STRADA_PROJECT_ID, token: env.STRADA_TOKEN, service: "api", environment: env.ENVIRONMENT, }) export default { fetch(request) { try { return handleRequest(request) } catch (err) { captureException(err) return new Response("error", { status: 500 }) } }, } satisfies ExportedHandler<Env>
env from cloudflare:workers is available during module
evaluation, so there is no reason to initialize in Hono, Spiceflow, or other
per-request middleware. Workers do not have process.env.import { env } from "cloudflare:workers" import { initStrada, startSpan } from "@strada.sh/sdk" initStrada({ projectId: env.STRADA_PROJECT_ID, token: env.STRADA_TOKEN, service: "api", environment: env.ENVIRONMENT, }) export default { fetch(request) { return startSpan({ name: "process-order" }, async (span) => { span.setAttribute("order.id", "ord_123") // ... return new Response("ok") }) }, } satisfies ExportedHandler<Env>
flush(), no ctx.waitUntil(), no special imports. The SDK auto-flushes via waitUntil from cloudflare:workers whenever telemetry is emitted. If nothing is emitted, zero HTTP requests.flush() explicitly before returning:import { env } from "cloudflare:workers" import { initStrada, getLogger, flush } from "@strada.sh/sdk" initStrada({ projectId: env.STRADA_PROJECT_ID, token: env.STRADA_TOKEN, service: "cron", environment: env.ENVIRONMENT, }) export default { async scheduled() { const logger = getLogger("alerts") logger.info({ message: "cron started" }) await doWork() logger.info({ message: "cron finished" }) // Flush before returning so all buffered logs are exported await flush() }, } satisfies ExportedHandler<Env>
flush(), the BatchLogRecordProcessor may still be buffering the last few log records when the handler returns and the isolate shuts down.// wrangler.jsonc { "observability": { "traces": { "enabled": true } } }
nodejs_compat compatibility flag for AsyncLocalStorage context propagation:// wrangler.jsonc { "compatibility_flags": ["nodejs_compat"] }
@strada.sh/sdk resolves differently by runtime:OTLPTraceExporter to /v1/tracesOTLPLogExporter to /v1/logsOTLPTraceExporter to /v1/tracesOTLPLogExporter to /v1/logsOTLPMetricExporter to /v1/metricsOTLPTraceExporter to /v1/traces (auto-flushed via waitUntil)OTLPLogExporter to /v1/logs (auto-flushed via waitUntil)telemetry in initStrada():initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "api", telemetry: { traces: { scheduledDelayMillis: 1000, maxExportBatchSize: 128, maxQueueSize: 1024, exportTimeoutMillis: 10000, }, logs: { scheduledDelayMillis: 1000, maxExportBatchSize: 128, }, metrics: { exportIntervalMillis: 5000, exportTimeoutMillis: 3000, }, }, })
telemetry.traces uses the same shape as OTel batch span processor browser configtelemetry.logs uses the same shape as OTel batch log record processor browser configtelemetry.metrics uses the same shape as PeriodicExportingMetricReaderOptions, minus exporter internals5000ms512204830000msPeriodicExportingMetricReader with an explicit export interval of 10 seconds in this SDK.spans/logs: batched, usually every ~5s metrics: periodic export every 10s
flush() → flush buffered telemetry without tearing down the SDKshutdown() → flush and shut down the SDK/providersuncaughtException captures the error, flushes logs + traces + metrics, then exitsSIGTERM / SIGINT call shutdown()beforeExit calls flush() for natural exits (event loop drains, a CLI calls process.exit() from a later tick, or a short-lived script finishes). This is the case that signals misstelemetry.metrics is currently only meaningful on Node.js. Workers do not configure a metric exporter; metrics.getMeter() returns a noop on Workersflush()). This matters for short-lived or externally-killed processes:| Exit path | Buffered telemetry flushed? |
| Event loop drains naturally | Yes, via beforeExit |
SIGTERM / SIGINT | Yes, via shutdown() |
uncaughtException | Yes, then exits |
process.exit() in the same synchronous tick | Only what already flushed; beforeExit does not fire on an immediate process.exit() |
SIGKILL (kill -9) | No. The OS terminates immediately; nothing can run |
SIGKILL, or you call process.exit() right after emitting telemetry, the last buffered spans/logs are lost. For those cases, call flush() explicitly at a controlled boundary (for example right after an important span ends), or lower telemetry.traces.scheduledDelayMillis so the batch timer fires sooner.import { startSpan, flush } from "@strada.sh/sdk" await startSpan({ name: "critical.op" }, async (span) => { // ... important work ... }) // guarantee export even if this process is killed seconds later await flush()
waitUntil from cloudflare:workers whenever telemetry is emittedflush() is available for manual use but rarely neededflush() calls forceFlush() on the tracer and logger providersshutdown() removes listeners, ends the current pageview, and shuts down the providersvisibilitychange: hiddenpagehide fallback, mainly for Safari compatibilitytelemetry.traces.disableAutoFlushOnDocumentHide or telemetry.logs.disableAutoFlushOnDocumentHidefetch with keepalive: true when possible, which improves the chance that an export already in progress can finish during page teardown. But there is still no hard guarantee that telemetry buffered in memory right before tab close will be delivered.visibilitychange when the document becomes hiddenpagehide as a fallbackfetch(..., { keepalive: true })navigator.sendBeacon() directly in the current installed OTel path.flush() yourself at a controlled boundary.session.id and user.id from the browser to the backend using W3C Baggage. Every outgoing fetch/XHR request from the browser includes both traceparent and baggage HTTP headers.Browser Server session.id = abc BaggageSpanProcessor reads baggage: user.id = user_123 session.id ──► span attribute │ user.id ──► span attribute │ fetch POST /api/checkout │ headers: BaggageLogProcessor reads baggage: │ traceparent: 00-abc123... session.id ──► log attribute │ baggage: strada.session.id=abc, user.id ──► log attribute │ user.id=user_123 ▼ ────────────────────────────────────────► request arrives with baggage
session.id and user.id. No app code needed.logs.getLogger().emit()) are correlated to the browser sessionSELECT Timestamp, ServiceName, LogAttributes['event.name'] AS event FROM otel_logs WHERE LogAttributes['session.id'] = {session_id:String} ORDER BY Timestamp ASC
CompositePropagator with W3CTraceContextPropagator + W3CBaggagePropagatorPageviewContextManager injects current baggage (session.id + user.id) into the OTel context on every outgoing requestBaggageSpanProcessor reads the baggage and sets session.id / user.id as span attributesBaggageLogProcessor does the same for log recordsidentifyUser() or cookie strada_uid changes, the next outgoing request carries the updated user.id. Cookie strada_vid is the visitor and is not in baggage.WebTracerProviderLoggerProvidererror and unhandledrejection handlersScript error. and extension framesNodeTracerProvider for traces with AsyncLocalStorageContextManagerMeterProvider for metricsLoggerProvider for logsBaggageSpanProcessor and BaggageLogProcessortelemetry.sdk.*, process.*, host.*, OTEL_RESOURCE_ATTRIBUTES)uncaughtException and unhandledRejection handlersflush() and shutdown() helpers for graceful process exitBasicTracerProvider for traces with AsyncLocalStorage context manager (requires nodejs_compat)LoggerProvider for logsBaggageSpanProcessor and BaggageLogProcessorwaitUntil from cloudflare:workers (no manual flush needed)cloud.provider: cloudflare and cloud.platform: cloudflare.workers resource attributesmetrics.getMeter() returns a noop on Workers. Use Cloudflare Analytics Engine or Workers Observability for metrics insteadpnpm add @opentelemetry/auto-instrumentations-node
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node" import { registerInstrumentations } from "@opentelemetry/instrumentation" import { initStrada } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "api", }) // Call after initStrada() so the global providers are registered registerInstrumentations({ instrumentations: [getNodeAutoInstrumentations()], })
http, https, fetch, express, fastify, koa, pg, mysql, mongodb, redis, ioredis, grpc, graphql, aws-sdk, fs, dns, net, and many more. See the full list.fetch, XMLHttpRequest, document load timing, and user interactions (clicks, navigation). Useful for seeing how long page loads and API calls take without adding manual spans.pnpm add @opentelemetry/auto-instrumentations-web
import { getWebAutoInstrumentations } from "@opentelemetry/auto-instrumentations-web" import { registerInstrumentations } from "@opentelemetry/instrumentation" import { initStrada } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "frontend", }) registerInstrumentations({ instrumentations: [getWebAutoInstrumentations()], })
fetch() and XMLHttpRequest call becomes a span with URL, method, status code, and duration// wrangler.jsonc { "observability": { "traces": { "enabled": true } } }
captureException() always normalizes the input to an Error, runs ignore filters, applies beforeSend, then emits a log record.beforeSend can:ErrorErrornull to drop the event500 never reaches it, so it never reaches Strada either. Those are exactly the errors worth seeing: webhook handlers, queue consumers, cron jobs, retry loops, and any code returning errors as values instead of throwing.captureException() at the point you swallow the error:import { captureException } from "@strada.sh/sdk" // Stripe webhook: the error is handled inline and never thrown if (result instanceof Error) { captureException(result, { tags: { route: "stripe-webhook", eventType: event.type }, }) return new Response("Webhook handler failed", { status: 500 }) }
tags with at least a route or handler identifier, otherwise the issue has nothing to filter by. console.error() is not a substitute: it is not sent to Strada.event.name.SELECT Timestamp, ServiceName, LogAttributes['event.name'] AS event_name, LogAttributes['user.id'] AS user_id, LogAttributes['session.id'] AS session_id, LogAttributes['url.path'] AS url_path FROM otel_logs WHERE mapContains(LogAttributes, 'event.name') ORDER BY Timestamp DESC LIMIT 100
event.name.@strada.sh/sdk. You usually do not need /node or /browser. Workers resolve automatically via the workerd export conditionotel_errorssession.id, not a single session-wide tracesession.id and user.idinitStrada(options)startSpan({ name }, callback) — creates a span, auto-ends, auto-records errorsstartInactiveSpan({ name }) — creates a detached span (manual end)captureException(error, opts?)track(name, properties?) — product analytics event, see typed catalogsetTags(tags)flush()shutdown()tracelogsmetricscontextpropagationSpanStatusCodeSpanKindgetLogger() over console.*console.log() and other console methods are not sent to Strada. They only appear in platform-specific logs (Cloudflare Workers dashboard, Node stdout) and are not queryable with SQL. Use getLogger() instead so your logs land in otel_logs and are searchable with the Strada CLI and TUI.import { initStrada, getLogger } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "api", }) const logger = getLogger("api") // These are queryable with `strada logs` and SQL logger.info({ message: "checkout started", checkoutId: "chk_123" }) logger.error({ message: "payment failed", error: String(err) })
import { getLogger } from "@strada.sh/sdk" const sdkLogger = getLogger("api") export const logger = { info(...args: Parameters<typeof sdkLogger.info>) { console.log(...args) sdkLogger.info(...args) }, warn(...args: Parameters<typeof sdkLogger.warn>) { console.warn(...args) sdkLogger.warn(...args) }, error(...args: Parameters<typeof sdkLogger.error>) { console.error(...args) sdkLogger.error(...args) }, debug(...args: Parameters<typeof sdkLogger.debug>) { console.debug(...args) sdkLogger.debug(...args) }, }
getLogger() directly when you only care about Strada-queryable logs.startSpan() for spans (auto-end, auto-error recording)trace.getTracer()) when you need full span controlcaptureException() when you want Strada error conventions@strada.sh/sdk is a thin OTel setup layer.