obs-unified

SDKs and MCP

First-party SDKs for TypeScript, Go, and Rust, a browser SDK for usage and replay, and an MCP server for agent investigations. They share one identity-propagation contract.

obs-unified ships first-party SDKs for three backend languages plus a browser SDK and an MCP server for AI agents, all sharing one identity-propagation contract.

For a compact method-by-method cheat sheet, see SDK API reference.

For the agent-facing investigation surface, see MCP server.

Backend SDKs

LanguageInstallRegistryWhere it runs
TypeScript (Fetch / Edge / Node)pnpm add @obsunified/telemetry-sdknpmCloudflare Workers · Hono · Next.js · Bun · Deno · Node.js
TypeScript (Node OTel SDK wrapper)pnpm add @obsunified/sdknpmNode.js 20+ with @opentelemetry/sdk-node auto-instrumentation (Express, Fastify, NestJS, pg)
Gogo get github.com/dev-sawan-ruparel/obs-unified/sdks/go@latestpkg.go.devAny Go 1.22+ binary (no vcs.commit.id / obs.config.digest or feature-flag cohorts yet)
Rustcargo add obs-unifiedcrates.ioAny Rust 1.75+ binary (Tokio runtime; no vcs.commit.id / obs.config.digest or feature-flag cohorts yet)

Which TypeScript backend package should I choose?

  • Choose @obsunified/telemetry-sdk for Cloudflare Workers, Hono, Next.js, Bun, Deno, or lightweight Node.js services where you want zero-dependency fetch-based OTLP export, structured logging, AI call tracking, and runtime lineage in one package.
  • Choose @obsunified/sdk when your Node.js service already uses @opentelemetry/sdk-node auto-instrumentation (Express, Fastify, NestJS, pg, ioredis) and you want a 1-line wrapper that wires the standard Node OpenTelemetry SDK to the obs-unified collector.

All backend SDKs expose the same core surface, so muscle memory ports across runtimes:

ConceptTypeScriptGoRust
InitinitObservability(config)obs.Init(ctx, cfg)obs_unified::init(cfg)
HTTP stampstampInteractionFromRequest(span, req)obs.StampInteraction(ctx, r)obs_unified::stamp_interaction(span, req)
LLM spanstartLLMSpan(name, opts)obs.StartLLMSpan(ctx, name, opts)start_llm_span(name, opts)
Tool spanstartToolSpan(name, opts)obs.StartToolSpan(ctx, name, opts)start_tool_span(name, opts)
Project IDextraHeaders: { "x-project-id": id }obs.SetProjectID(ctx, id)set_project_id(id)

Go and Rust lineage gap. The Go and Rust SDKs do not yet emit vcs.commit.id, obs.config.digest, or feature-flag cohort attributes, so change correlation by commit and by flag cohort works for TypeScript services only today. They still send service.version and the environment.

These are deliberately thin wrappers around OpenTelemetry: one-line collector init, OpenInference-shaped helpers so LLM/tool spans render in the dashboard's AI tab, runtime lineage (service.version, vcs.commit.id, deployment.environment.name) for AutoSRE change correlation, project-id propagation for multi-tenant, and the loop-guard header for services that ingest their own telemetry.

If your backend is in Python, Java/JVM, .NET, Ruby, PHP, or Elixir, standard OpenTelemetry OTLP/HTTP exporters work natively with 100% feature parity—simply copy the inbound x-obs-interaction request header onto the active span as obs.interaction.id (see the Python Flask walkthrough for a 4-line request hook).

Browser SDK

PackageWhere it runsWhat it does
@obsunified/analytics-sdkBrowser (React or vanilla)Tracks usage events, identifies users, mints interaction_id on every click, auto-injects x-obs-interaction on outbound fetch/XHR

This one is browser-only by definition — there's no parallel Go/Rust analytics SDK because that surface area is the web.

All four packages live in the obs-unified monorepo. The TypeScript packages publish to public npm under the @obsunified organization. The Go SDK is consumed from the public sdks/go module path through Go module tags like sdks/go/v0.1.1 and is indexed on pkg.go.dev. The Rust SDK publishes to crates.io as obs-unified. The full list of published artifacts, including the all-in-one container image on GHCR, is in Installation.

MCP server

PackageWhere it runsWhat it does
@obsunified/mcp-serverLocal MCP host over stdioExposes read-only investigation tools for status, recent traces, trace detail, service maps, logs, AI sessions, users, replays, profiles, evals, compact evidence bundles, connected signals, agent runs, actions, and tool calls

The MCP server is intentionally a read boundary. Agents can inspect the same graph a human sees in the dashboard, but they do not need the write-only telemetry ingest key used by app SDKs. Configure it with a dashboard token, a compatible read bearer, or a local obs_session cookie for development.

See MCP server for install and host configuration.

[!NOTE] The MCP server uses the @obsunified scope on public npm (@obsunified/mcp-server). The first-party SDKs use the @obsunified scope on public npm (@obsunified/*).

npm install

pnpm add @obsunified/telemetry-sdk
pnpm add @obsunified/analytics-sdk

The identity skeleton

The browser SDK owns the left half (user / session / interaction). The server SDK owns the right half (trace / span). Agent helpers and MCP context propagation extend that same skeleton across agent runs, actions, and tool calls. They meet at the x-obs-interaction request header that the browser sets on outbound fetches and the server stamps onto the resulting span.

Once a signal carries interaction_id, the dashboard's Connected Rail can pivot from any entity to "the click that caused this trace" in one click.

For CPU and off-CPU work, interaction_id is the entry key, not the profiler's storage key. Profiles are indexed by trace_id; when a profiled service labels samples with trace IDs, the path becomes:

@obsunified/analytics-sdk

Install

pnpm add @obsunified/analytics-sdk

For React hosts the SDK exports a provider component; for other hosts call the lower-level installAutoCorrelate() directly.

React quick start

import { AnalyticsProvider, AnalyticsErrorBoundary } from "@obsunified/analytics-sdk/react";

declare const ErrorPage: () => null;
declare const App: () => null;

export function Root() {
	return (
		<AnalyticsProvider
			collectorUrl="https://your-collector.example.com"
			apiKey={(import.meta as unknown as { env: Record<string, string> }).env.VITE_OBS_INGEST_KEY}
			trackPageViews
			captureErrors
			storagePrefix="myapp"
		>
			<AnalyticsErrorBoundary context="App" fallback={<ErrorPage />}>
				<App />
			</AnalyticsErrorBoundary>
		</AnalyticsProvider>
	);
}

The provider:

  • Mints a session_id on first mount, persists it for ~30 minutes of inactivity
  • Tracks page views automatically on pushState / popstate
  • Captures uncaught errors and unhandled promise rejections
  • Installs the Mode A auto-correlator: a global capture-phase listener on click, submit, keydown that mints a fresh interaction_id, plus a global fetch + XMLHttpRequest wrapper that injects the x-obs-interaction header on outbound requests
  • Provides useAnalytics() hook for explicit calls

Interaction ids are 26-character uppercase Crockford-base32 ULIDs (for example 01JAQ8K3Z9X7M2N4P6Q8R0S1T3). The browser SDK mints valid ids; if you stamp x-obs-interaction by hand (curl tests, other languages), use that format, because @obsunified/telemetry-sdk silently drops malformed values.

Inside a component

import { useAnalytics } from "@obsunified/analytics-sdk/react";

export function CheckoutButton() {
	const { trackInteraction, identify, withInteraction } = useAnalytics();

	const onSubmit = withInteraction(async () => {
		// fetch() inside here automatically carries x-obs-interaction
		const res = await fetch("/api/checkout", { method: "POST" });
		trackInteraction("checkout_submitted", { status: res.status });
	});

	return <button onClick={onSubmit}>Place order</button>;
}

Identifying users

identify("user-42", { email: "[email protected]", plan: "pro" });

Calls POST /v1/identify on the collector with userId, visitorId, email, name, properties. The endpoint upserts into user_profiles and uses MIN(...) to preserve the earliest first_seen_at on conflict.

For historical imports / backfills, you can also pass an optional firstSeenAt ISO timestamp:

await fetch(`${collector}/v1/identify`, {
	method: "POST",
	body: JSON.stringify({
		userId: "user-42",
		visitorId: "vis-abc",
		email: "[email protected]",
		firstSeenAt: "2026-01-15T08:30:00Z",
	}),
});

Future timestamps are rejected silently — clock-skewed clients can't poison the table.

Mode B: explicit interaction context

Auto-correlation (Mode A) handles synchronous handlers and shallow await chains. Deeper async flows (debounces, setTimeout-queued work, state-machine transitions) escape the microtask cascade. For those, use withInteractionContext:

import { withInteractionContext, currentInteractionId } from "@obsunified/analytics-sdk";

// Capture at click time
const id = currentInteractionId();

// Re-enter the context wherever the work actually happens
setTimeout(() => {
	withInteractionContext(id!, () => {
		fetch("/api/long-running"); // carries the click's interaction_id
	});
}, 500);

@obsunified/telemetry-sdk

Install

pnpm add @obsunified/telemetry-sdk

Cloudflare Worker / Hono quick start

import {
	createRequestSpan,
	initObservability,
	recordFeatureFlag,
	runWithSpan,
	stampInteractionFromRequest,
	flushSpans,
	flushLogs,
	flushAICalls,
} from "@obsunified/telemetry-sdk";

app.use("*", async (c, next) => {
	initObservability({
		collectorUrl: c.env.OBS_COLLECTOR_URL,
		apiKey: c.env.OBS_INGEST_KEY,
		serviceName: "checkout-api",
		environment: c.env.OBS_ENVIRONMENT ?? "prod",
		serviceVersion: c.env.OBS_SERVICE_VERSION ?? "2.4.1",
		commitSha: c.env.OBS_COMMIT_SHA,
	});
	await next();
});

app.use("*", async (c, next) => {
	const span = createRequestSpan("checkout-api", `${c.req.method} ${c.req.path}`);
	span.setAttribute("http.request.method", c.req.method);
	// RFC 0004 — closes the click-to-trace loop. No-op if header is missing.
	stampInteractionFromRequest(span, c.req.raw);

	try {
		await runWithSpan(span, () => next());
		span.setStatus(c.res.status >= 400 ? 2 : 1);
	} finally {
		span.end();
		await Promise.all([flushSpans(), flushLogs(), flushAICalls()]);
	}
});

The same primitives work in plain Node.js — replace c.env with process.env, replace the Hono context with a node request handler.

Child spans

import { withChildSpan } from "@obsunified/telemetry-sdk";

declare const db: { query(sql: string): Promise<unknown[]> };

const items = await withChildSpan("db.query.items", async (child) => {
	child.setAttribute("db.system", "postgres");
	child.setAttribute("db.statement", "SELECT * FROM items");
	return await db.query("SELECT * FROM items");
});

Child spans inherit the parent's trace context and interaction id automatically.

Logs

import { createLogger } from "@obsunified/telemetry-sdk";

declare const cartId: string;
declare const items: unknown[];
declare const traceId: string;
declare const error: unknown;

const logger = createLogger("checkout-api");

logger.info("Cart loaded", { cartId, itemCount: items.length });
logger.error("Stripe webhook signature failed", { traceId, error });

Logs are flushed at request end via flushLogs(). If a span is active, the log inherits trace_id, span_id, session_id, and interaction_id.

AI calls

The SDK exposes typed helpers for OpenInference-style LLM spans:

import { setAISessionContext, startRetrieverSpan } from "@obsunified/telemetry-sdk";

declare const sessionId: string;
declare const userId: string;
declare const query: string;
declare const db: { vectorSearch(q: string): Promise<{ id: string; score: number; content: string }[]> };

setAISessionContext({ sessionId, userId });

const retriever = startRetrieverSpan({ query, name: "vector.search" });
try {
  const docs = await db.vectorSearch(query);
  retriever.addDocuments(docs);
} finally {
  retriever.end();
}

For lower-level LLM and tool spans, use startLLMSpan() and startToolSpan() as shown in SDK API reference.

Cloudflare Workers bindings

Workers services can wrap platform bindings from the Cloudflare-specific entry point:

import { wrapD1, wrapFetch, wrapR2 } from "@obsunified/telemetry-sdk/cloudflare";

declare const env: {
  DATABASE: Parameters<typeof wrapD1>[0];
  STORAGE_BUCKET: Parameters<typeof wrapR2>[0];
};

const db = wrapD1(env.DATABASE);
const bucket = wrapR2(env.STORAGE_BUCKET, { bucketName: "blobs" });
const telemetryFetch = wrapFetch(globalThis.fetch);

Wire-format compatibility

The collector accepts standard OTLP/HTTP at:

  • POST /v1/traces — protobuf or JSON OTLP traces
  • POST /v1/logs — protobuf or JSON OTLP logs

So any OTel-compatible producer (Python, Java/JVM, .NET, the Go collector, OpenInference instrumentations, Beyla eBPF agent) works alongside the first-party SDKs. To enable the click-to-trace pivot in a native OTel SDK, copy the inbound x-obs-interaction request header onto the server span as obs.interaction.id (see Python Flask instrumentation).

IDE deep-linking configuration

The dashboard supports deep-linking from traces, spans, and profile frames directly to files in your local editor.

Saved IDE template

The selected editor scheme is saved in the browser's localStorage under the key obs_ide_template.

IDE selector dropdown

In the dashboard's top bar, the IdeSelector dropdown lets you select your preferred IDE:

  • VS Code: vscode://file/{absolutePath}:{lineNumber}
  • Cursor: cursor://file/{absolutePath}:{lineNumber}
  • WebStorm: webstorm://open?file={absolutePath}&line={lineNumber}
  • Custom: A custom URI template of your choice.

Custom templates

When configuring a custom IDE template, the following placeholders are dynamically replaced:

  • {absolutePath}: The absolute path of the file on your local machine (e.g. /Users/username/project/file.ts).
  • {relativePath}: The repository-relative path of the file (e.g. packages/dashboard/src/app.tsx).
  • {lineNumber}: The 1-indexed line number.

On this page