Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

@strada.sh/sdk

OpenTelemetry-first error tracking, tracing, logs, metrics, and browser analytics for Strada.
Import from @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.
Always import OpenTelemetry APIs from @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" }, }) }

Turning telemetry off

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", })
When it is off, 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
Never guard SDK calls behind your own flag, and never skip initStrada() to disable it. Skipping init makes every call print called before initStrada(). Pass enabled and leave your call sites clean.
A blank 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", })
An explicit endpoint (a local collector, for example) keeps export on even without a projectId.

Telemetry never throws

Observability must never be the reason an app goes down. The high-level telemetry functions catch internally and return the failure instead of throwing it, following the errore convention of errors as values:
const error = captureException(err) if (error) { // optional. The SDK already logged it once. }
FunctionReturns
captureException()Error | undefined
track()Error | undefined
trackPageview()Error | undefined
identifyUser()Error | undefined
initStrada()Error | undefined
flush() / shutdown()Promise<Error | undefined>
Almost every call site ignores the return value, which is fine: failures are also logged once with console.warn, deduplicated by message so a broken exporter cannot flood the console on every request.
This holds for inputs that are hostile by accident. 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
A beforeSend hook that throws does not lose the report either: the original error is sent and the hook failure is logged.
The one deliberate exception is 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 })
Logger methods (getLogger().info() and friends) stay void because StradaLogger extends the OTel Logger interface, but they never throw either.
The guarantee covers the functions in the table above. Low-level exports (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.

Use normal OpenTelemetry APIs

After 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 release metadata

Use the Vite plugin in browser apps so Strada can tag every trace, log, and error with the exact build that produced it. Browsers cannot read deployment platform env vars at runtime, so the plugin reads them during 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()], })
Then initialize the browser SDK normally. You do not need to pass release fields manually unless you want to override the detected values. Do not pass 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", })
The plugin uses existing platform variables when present, then falls back to local git for the commit and branch during local builds. If the platform does not provide a deployment id, Strada uses the commit SHA as deployment.id so every deployment still has a stable identifier.
MetadataPlatform env varsStandard OTel resource attribute
Release/versionSTRADA_RELEASE_VERSION, STRADA_RELEASE, SENTRY_RELEASE, npm_package_versionservice.version
Commit SHAVERCEL_GIT_COMMIT_SHA, RENDER_GIT_COMMIT, CF_PAGES_COMMIT_SHA, WORKERS_CI_COMMIT_SHA, GITHUB_SHAvcs.ref.head.revision
Branch/refVERCEL_GIT_COMMIT_REF, RENDER_GIT_BRANCH, CF_PAGES_BRANCH, WORKERS_CI_BRANCH, GITHUB_HEAD_REF, GITHUB_REF_NAMEvcs.ref.head.name
Deployment idVERCEL_DEPLOYMENT_ID, WORKERS_CI_BUILD_UUID, RENDER_INSTANCE_ID, FLY_MACHINE_VERSION, GITHUB_RUN_ID, otherwise commit SHAdeployment.id
GitHub Actions sets 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 }}
You can override any value explicitly:
stradaVitePlugin({ version: "frontend@1.4.2", releaseCommit: "9f3a12b0c45d...", releaseBranch: "main", deploymentId: "dpl_123", })
These are standard OpenTelemetry resource attributes. Strada stores them in 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.

User Identity

Pass 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, })
When login/logout happens after initialization, call 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
Call 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, })
Only 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.
Every server 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.

Default instrumentation

The SDK does not install the OTel auto-instrumentation packages by default. It does not monkey-patch 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.
RuntimeAutomatic tracesAutomatic logs
BrowserA pageview span starts on initStrada(), restarts on SPA navigation, and ends when the tab is hiddenUncaught window.error and unhandledrejection events are sent as exception logs
NodeNo spans are created automaticallyuncaughtException and unhandledRejection are sent as exception logs
Cloudflare WorkersNo spans are created automaticallyNo process/global error handlers. Only explicit SDK calls send logs
Everything else is explicit. Use 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
Install optional OTel auto-instrumentation separately if you want automatic HTTP, database, document load, or interaction spans. See Auto-instrumentation (optional).

Create traces and spans

Use 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. })

Parent and child spans

Nesting 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() }) })
Spans from different tracer instances also nest correctly. Parent-child is determined by the active OTel Context, not by which tracer created the span. So spans from 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.
Browser limitation: async nesting (across 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 startInactiveSpan

Use startSpan 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.
The difference matters because the active span determines what future spans get parented to. With 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.
In a trace viewer:
startSpan produces a nested tree startInactiveSpan produces flat siblings process-order ████████████████████ process-order ████████████████████ ├─ validate ████████ ├─ send-email ██████████████ └─ charge ████████████ ├─ send-webhook █████████████ └─ log-audit █████

When to use startSpan

Use it for sequential work with sub-steps where you want a hierarchy in the trace viewer.
import { 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) }) })
Other good uses: database transactions with multiple queries, multi-step pipelines, middleware chains, any operation where the sub-steps are logically "inside" the parent.

When to use startInactiveSpan

Use it for fire-and-forget work, parallel fan-out, or background tasks that run independently and should not parent anything that happens after them.
import { 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()), ]) })
Other good uses: scheduling a job onto a queue (the span measures the enqueue, not the job execution), starting a timer or interval, kicking off a cache warm that completes later.
// 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 spans

startInactiveSpan 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
This works for both normal exits and throws. If an error is thrown inside the block, the span is still ended.
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.

Raw OTel tracing API

If you need full control, the standard OTel 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() } })

Logging

For app logs, use 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" })
These methods are intentionally not exception capture. 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.

Structured logs and attributes

Pass one plain object as the only argument to emit a structured log. The object becomes 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, })
This lands in otel_logs like:
{ Body: "checkout started", SeverityText: "INFO", LogAttributes: { message: "checkout started", checkoutId: "chk_123", "user.id": "user_123", plan: "pro", retry: "false", }, }
If you pass multiple arguments, the call is treated like 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 = {}
Use raw OTel .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.

Emit logs with raw OpenTelemetry

The standard OTel logs API is logs.getLogger().emit(). In most cases, severityNumber is enough. severityText is optional.
Important: 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", }, })

Error logs with raw OTel

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 ?? "", }, }) }

Same thing with Strada helpers

For analytics-style events, prefer track():
import { initStrada, track } from "@strada.sh/sdk" initStrada({ projectId: "01JTHG5M7XPQR8KNCZ0W4D", service: "frontend", }) track("checkout_started", { plan: "pro", source: "pricing-page", })
For errors, prefer 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", }) }

Identify the current user

The SDK reads the user ID from cookie 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
The cookie must be JS-readable (not httpOnly) so the browser SDK can access it via document.cookie.
Set 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() })

Better Auth

With Better Auth, prefer the Strada plugin. It sets the 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(), ], })
The plugin emits 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 }).

Server-side user identification without Better Auth

The Better Auth plugin is only a convenience. Any auth stack gets the same result with two standard calls, and neither depends on a library.
1. Put the user into baggage for the request. Baggage is what the SDK reads: 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) })
For browser-initiated requests this is already done for you: the browser SDK propagates 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.
2. Send the profile once at login so 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, })
This writes one row to otel_users. Telemetry rows keep carrying only user.id, so no PII is copied into every span and log.

Frameworks without middleware

When the user is only known inside a handler, or the framework has no middleware layer, wrap the work in the same context. A local helper keeps it to one line per call site:
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) })

Cloudflare Workers

Identical API. The Workers entry installs an 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)) }, }

Background work with no request context

Cron handlers, queue consumers, Durable Object alarms, and deep callbacks often have no context to inherit. Either wrap them in 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 })
Explicit properties land as custom.user_id, baggage lands as user.id. Pick one convention per project so queries stay simple.

Browser pageview tracking

The browser SDK automatically tracks pageviews as OTel spans in 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.
How it works:
  • On initStrada(), a span named "pageview" starts for the current URL
  • Every span gets session.id, visitor.id, url.path, url.query, url.full, user.id injected automatically
  • Manual spans, custom events, and optional auto-instrumentation are parented to the active pageview when no other span is active
  • On SPA navigation (detected via the Navigation API), the old span ends and a new one starts
  • On tab close (visibilitychange: hidden), the current span ends and flushes
The pageview span shape:
{ "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" } }
SPA navigation is auto-detected across all frameworks (Next.js, React Router, Vue Router, SvelteKit) via the Navigation API. Each navigation ends the current pageview and starts a new one with navigation.type ("push", "replace", "traverse") and navigation.user_initiated attributes.
Two materialized views in ClickHouse/Tinybird pre-aggregate pageview spans at ingest time into compact tables (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.

Custom events (product analytics)

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", })
These events are emitted as log records, not spans. Later you can query them from otel_logs using event.name.
Custom properties are automatically prefixed with custom. so they don't collide with standard OTel attributes:
track("purchase", { plan: "pro", // stored as custom.plan amount: 49, // stored as custom.amount })
In the browser, the log record is automatically correlated to the active pageview span via 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.
Call 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().

Type-safe event catalog

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) }
Call sites are now checked. A wrong name, a missing property, or a misspelled key fails the build:
trackEvent("purchase", { plan: "pro", amount: 49 }) // ok trackEvent("purchse", { plan: "pro", amount: 49 }) // error: unknown event trackEvent("purchase", { plan: "pro" }) // error: missing amount
Property values must stay flat primitives (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.
The catalog is also the right place for the rule that events must never carry prompts, raw user input, tokens, or API keys. Keep it to stable identifiers, names, counts, and booleans.
Sharing a catalog across runtimes. When a Worker and a Node server send to the same project, keep the catalog in one module with no runtime exports, types only, and import it with 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"
Each runtime then defines its own one-line 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

What it sends

browser / server code ├─ traces ─────────────── /v1/traces ├─ logs ─────────────── /v1/logs └─ metrics ─────────────── /v1/metrics Strada collector ┌─────────────────┴─────────────────┐ otel_traces otel_logs

Browser spans

The browser runtime adds:
  • session.id from sessionStorage
  • visitor.id from cookie strada_vid
  • url.path, url.query, url.full
  • http.request.header.referer
  • user.id from cookie strada_uid or StradaOptions.userId
It also starts a pageview span and usually parents later browser work to that pageview when no other span is active.

Browser custom events

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" } }

Browser pageview hierarchy

trace: pageview /pricing ├─ span: pageview ├─ span: load-pricing-plans └─ log: signup_started
Important detail: session.id is the stable browser session identifier. It is not one giant tab-wide TraceId.

Exception logs

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" } }
The collector later extracts these into the otel_errors table for grouping and issue views.

Cloudflare Workers

The Workers runtime is a minimal, opt-in only entry. No automatic instrumentation, no automatic spans. The SDK only sends data when you explicitly call captureException(), trace.getTracer(), or logs.getLogger(). If none of these are called, zero HTTP requests are made to the collector.
This keeps the bundle small and avoids the per-request billing overhead of automatic instrumentation. For automatic instrumentation of KV, D1, Durable Objects, fetch, etc., use Cloudflare's built-in tracing instead (see below).
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>
Initialize at module scope, before the Worker or framework creates its first span or logger. 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.
Manual spans and logs work the same way:
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>
No 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.

Flushing in scheduled handlers and Durable Objects

The auto-flush works well for HTTP request handlers. For scheduled (cron) handlers, queue consumers, and Durable Object alarms, logs emitted near the end of execution may not flush before the isolate terminates. Call 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>
Without the explicit flush(), the BatchLogRecordProcessor may still be buffering the last few log records when the handler returns and the isolate shuts down.

Cloudflare built-in tracing (automatic instrumentation)

For automatic instrumentation, use Cloudflare's built-in tracing. It instruments at the runtime level (KV, D1, Durable Objects, fetch, handler invocations) without any SDK overhead:
// wrangler.jsonc { "observability": { "traces": { "enabled": true } } }
This works alongside the Strada SDK. Use built-in tracing for automatic spans, and the SDK for error capture, custom events, and manual spans.

Workers requirements

The Workers entry requires the nodejs_compat compatibility flag for AsyncLocalStorage context propagation:
// wrangler.jsonc { "compatibility_flags": ["nodejs_compat"] }

Under the hood

Root import uses conditions

@strada.sh/sdk resolves differently by runtime:
  • browser bundlers get the browser implementation (WebTracerProvider, session management, pageview spans)
  • Cloudflare Workers get the Workers implementation (BasicTracerProvider, auto-flush via waitUntil, no automatic spans)
  • Node.js / Bun / Deno get the server implementation (NodeTracerProvider, process handlers, resource detection)
That means the same import path can configure different OTel SDKs without user changes.

Exporters, batching, and flush behavior

By default the SDK uses OTLP HTTP JSON exporters for every signal:
  • browser tracesOTLPTraceExporter to /v1/traces
  • browser logsOTLPLogExporter to /v1/logs
  • Node tracesOTLPTraceExporter to /v1/traces
  • Node logsOTLPLogExporter to /v1/logs
  • Node metricsOTLPMetricExporter to /v1/metrics
  • Workers tracesOTLPTraceExporter to /v1/traces (auto-flushed via waitUntil)
  • Workers logsOTLPLogExporter to /v1/logs (auto-flushed via waitUntil)
You can tune batching and cadence with 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, }, }, })
The SDK intentionally reuses familiar OTel option names:
  • telemetry.traces uses the same shape as OTel batch span processor browser config
  • telemetry.logs uses the same shape as OTel batch log record processor browser config
  • telemetry.metrics uses the same shape as PeriodicExportingMetricReaderOptions, minus exporter internals

Trace and log batching defaults

Spans and logs are not sent one-by-one. They go through the standard OTel batch processors.
Unless you override OTel env vars, the default batch behavior is:
  • scheduled delay: 5000ms
  • max export batch size: 512
  • max queue size: 2048
  • export timeout: 30000ms
So in normal operation, ended spans and emitted logs are usually exported within about 5 seconds, or sooner if the batch fills up.

Node metrics cadence

Node metrics use PeriodicExportingMetricReader with an explicit export interval of 10 seconds in this SDK.
spans/logs: batched, usually every ~5s metrics: periodic export every 10s

Manual flush and shutdown

The SDK exposes:
  • flush() → flush buffered telemetry without tearing down the SDK
  • shutdown() → flush and shut down the SDK/providers
On Node.js, the SDK registers process handlers so buffered telemetry is not lost on exit:
  • uncaughtException captures the error, flushes logs + traces + metrics, then exits
  • SIGTERM / 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 miss
  • telemetry.metrics is currently only meaningful on Node.js. Workers do not configure a metric exporter; metrics.getMeter() returns a noop on Workers

Process lifetime and span export

Spans only export once they end and the batch processor flushes (the ~5s timer, a full batch, a registered process handler, or an explicit flush()). This matters for short-lived or externally-killed processes:
Exit pathBuffered telemetry flushed?
Event loop drains naturallyYes, via beforeExit
SIGTERM / SIGINTYes, via shutdown()
uncaughtExceptionYes, then exits
process.exit() in the same synchronous tickOnly what already flushed; beforeExit does not fire on an immediate process.exit()
SIGKILL (kill -9)No. The OS terminates immediately; nothing can run
If a parent process kills the SDK process with 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()
On Cloudflare Workers:
  • Auto-flush via waitUntil from cloudflare:workers whenever telemetry is emitted
  • Multiple span ends / log emits in the same microtask share one flush
  • If no telemetry is emitted, zero HTTP requests are made
  • flush() is available for manual use but rarely needed
On the browser:
  • flush() calls forceFlush() on the tracer and logger providers
  • shutdown() removes listeners, ends the current pageview, and shuts down the providers

Browser page close behavior

The browser SDK relies on the standard OTel browser batch processor behavior.
That means:
  • pageviews end on visibilitychange: hidden
  • OTel batch processors auto flush on document hide by default
  • OTel browser processors also install a pagehide fallback, mainly for Safari compatibility
  • you can turn that off with telemetry.traces.disableAutoFlushOnDocumentHide or telemetry.logs.disableAutoFlushOnDocumentHide
The browser OTLP HTTP exporter uses fetch 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.
Strada does not add its own extra unload hook on top of this. It relies on the upstream OTel browser behavior:
  • flush on visibilitychange when the document becomes hidden
  • flush on pagehide as a fallback
  • export with fetch(..., { keepalive: true })
The browser exporter does not use navigator.sendBeacon() directly in the current installed OTel path.
Practical rule:
  • if the batch timer already fired, the data is likely already on the way
  • if the user closes the tab immediately after an event, some very recent telemetry may be lost
If this matters for a particular flow, call flush() yourself at a controlled boundary.

Browser-to-server context propagation

The SDK automatically propagates 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
This means backend spans and log records created within a browser-initiated request automatically carry the browser's session.id and user.id. No app code needed.
What this enables:
  • Backend custom events (emitted via logs.getLogger().emit()) are correlated to the browser session
  • Errors captured on the backend include the browser session context
  • You can query all events for a session across browser and backend in a single SQL query:
SELECT Timestamp, ServiceName, LogAttributes['event.name'] AS event FROM otel_logs WHERE LogAttributes['session.id'] = {session_id:String} ORDER BY Timestamp ASC
How it works under the hood:
  • The browser SDK registers a CompositePropagator with W3CTraceContextPropagator + W3CBaggagePropagator
  • The PageviewContextManager injects current baggage (session.id + user.id) into the OTel context on every outgoing request
  • The Node SDK registers the same composite propagator to extract baggage from incoming requests
  • BaggageSpanProcessor reads the baggage and sets session.id / user.id as span attributes
  • BaggageLogProcessor does the same for log records
Baggage updates live. When identifyUser() 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.

Browser runtime

The browser build sets up:
  • WebTracerProvider
  • LoggerProvider
  • OTLP HTTP exporters for traces and logs
  • W3C Baggage propagation for session.id and user.id
  • global error and unhandledrejection handlers
  • pageview span lifecycle
  • SPA navigation detection via the Navigation API
  • log filtering for noisy browser junk like Script error. and extension frames

Node runtime

The server build sets up:
  • NodeTracerProvider for traces with AsyncLocalStorageContextManager
  • MeterProvider for metrics
  • LoggerProvider for logs
  • OTLP HTTP exporters for traces, logs, metrics
  • W3C Baggage extraction with BaggageSpanProcessor and BaggageLogProcessor
  • Resource detection (telemetry.sdk.*, process.*, host.*, OTEL_RESOURCE_ATTRIBUTES)
  • global uncaughtException and unhandledRejection handlers
  • flush() and shutdown() helpers for graceful process exit

Workers runtime

The Workers build is minimal and opt-in only:
  • BasicTracerProvider for traces with AsyncLocalStorage context manager (requires nodejs_compat)
  • LoggerProvider for logs
  • OTLP HTTP exporters for traces and logs
  • W3C Baggage extraction with BaggageSpanProcessor and BaggageLogProcessor
  • Auto-flush via waitUntil from cloudflare:workers (no manual flush needed)
  • cloud.provider: cloudflare and cloud.platform: cloudflare.workers resource attributes
  • No automatic spans, no process handlers
  • No metrics provider. metrics.getMeter() returns a noop on Workers. Use Cloudflare Analytics Engine or Workers Observability for metrics instead
  • Zero HTTP requests to the collector unless user code creates telemetry

Auto-instrumentation (optional)

The SDK does not include auto-instrumentation by default. It only sets up providers, exporters, and error handlers. If you want automatic spans for HTTP requests, database queries, or browser interactions, install the OTel auto-instrumentation packages separately.

Node auto-instrumentation

Automatically creates spans for outgoing/incoming HTTP requests, database queries (pg, mysql, mongodb, redis), gRPC calls, and more. No code changes needed; it monkey-patches Node.js modules at import time.
pnpm 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()], })
This adds spans for 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.

Browser auto-instrumentation

Automatically creates spans for 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()], })
This adds spans for:
  • fetch / XHR — every fetch() and XMLHttpRequest call becomes a span with URL, method, status code, and duration
  • document load — spans for DNS, TCP, TLS, request, response, DOM processing, and load event timing
  • user interaction — spans for click events on interactive elements

Cloudflare Workers auto-instrumentation

Workers use Cloudflare's built-in tracing instead of a JS instrumentation package. It instruments at the runtime level (inside workerd), so it has zero bundle size impact and zero per-request overhead:
// wrangler.jsonc { "observability": { "traces": { "enabled": true } } }
This auto-instruments KV, D1, Durable Objects, fetch, handler invocations, and more. It supports OTLP export to external backends. Use it alongside the Strada SDK: built-in tracing for automatic spans, the SDK for error capture and custom events.

Why it's not built in

Auto-instrumentation packages are large. The Node metapackage pulls in ~30 instrumentation libraries, AWS/GCP resource detectors, gRPC bindings, and polyfills, adding ~2MB to the bundle. The browser package adds ~70kB. The Workers entry has no auto-instrumentation at all since Cloudflare's built-in tracing handles it at the runtime level with zero bundle cost. By keeping auto-instrumentation opt-in, the SDK stays lightweight for users who only need error tracking, custom events, or manual tracing.

Error handling semantics

captureException() always normalizes the input to an Error, runs ignore filters, applies beforeSend, then emits a log record.
beforeSend can:
  • return the same Error
  • return a rewritten Error
  • return null to drop the event

Errors handled inline need an explicit call

A framework's top-level error handler only sees errors that are thrown out of the request. An error you catch, log, and turn into a 500 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.
Call 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 }) }
Always pass 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.

Query shape later

Custom events are easy to query because they carry 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
That excludes ordinary logs that do not have event.name.

Important details

  • Import from @strada.sh/sdk. You usually do not need /node or /browser. Workers resolve automatically via the workerd export condition
  • Initialize early. On Node.js, do it before loading the rest of the app. On Workers, call it at the top of your handler (safe to call every request)
  • Custom events are logs, not spans
  • Exceptions are logs first. The collector extracts them into otel_errors
  • Browser sessions use session.id, not a single session-wide trace
  • Pageview spans are roots. Fetch/XHR/user-interaction spans usually become children of the current pageview
  • Session context propagates to the backend via W3C Baggage. Backend spans and logs within a browser-initiated request automatically get session.id and user.id

API summary

Main helpers

  • initStrada(options)
  • startSpan({ name }, callback) — creates a span, auto-ends, auto-records errors
  • startInactiveSpan({ name }) — creates a detached span (manual end)
  • captureException(error, opts?)
  • track(name, properties?) — product analytics event, see typed catalog
  • setTags(tags)
  • flush()
  • shutdown()

Re-exported OTel APIs

  • trace
  • logs
  • metrics
  • context
  • propagation
  • SpanStatusCode
  • SpanKind

Prefer getLogger() 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) })
If you also want local console output during development, create a thin wrapper:
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) }, }
This sends logs to both Strada and the local console. Use the wrapper in development, or use getLogger() directly when you only care about Strada-queryable logs.

When to use raw OTel vs Strada helpers

  • use startSpan() for spans (auto-end, auto-error recording)
  • use raw OTel APIs (trace.getTracer()) when you need full span control
  • use captureException() when you want Strada error conventions
  • use track() when you want analytics events in otel_logs, wrapped in a typed catalog

Summary

@strada.sh/sdk is a thin OTel setup layer.
It does not replace OpenTelemetry. It configures it correctly for Strada, adds a few useful conventions, then gets out of the way.