@aient/otel-browser
v0.6.0
Published
Minimal browser-only OpenTelemetry bootstrap for Aient
Readme
@aient/otel-browser
OpenTelemetry for the browser. Works out of the box, highly configurable.
Quick Start (3 lines)
npm install @aient/otel-browser @opentelemetry/api @opentelemetry/api-logsimport { registerOTelBrowser } from '@aient/otel-browser'
registerOTelBrowser({ serviceName: 'my-web-app' })Done. Traces go to https://ingest.aient.ai/v1/traces, logs to /v1/logs.
Local Development
For local development with a local collector:
registerOTelBrowser({
serviceName: 'my-web-app',
exporterUrl: 'http://localhost:4318',
})Production: Aient.dev Ingest
For production with Aient authentication:
registerOTelBrowser({
serviceName: 'my-web-app',
// exporterUrl defaults to https://ingest.aient.ai
publishableKey: 'pk_live_11...',
})Full Example (all options)
import { registerOTelBrowser } from '@aient/otel-browser'
const sdk = registerOTelBrowser({
// Identity
serviceName: 'my-web-app',
// Caller-owned installation ID (persist/rotate according to your consent policy)
installationId: '0198d2f4-1111-7111-8111-111111111111',
// Aient auth
publishableKey: 'pk_xxx',
// Release metadata (CRITICAL for source mapping)
release: {
commit: process.env.NEXT_PUBLIC_COMMIT_SHA, // or 'abc1234'
branch: process.env.NEXT_PUBLIC_COMMIT_REF, // or 'main'
version: '1.0.0',
environment: 'production',
},
// Export - base URL, SDK derives /v1/traces and /v1/logs
exporterUrl: 'https://ingest.aient.ai',
exporter: 'http/json',
exporterHeaders: { 'x-tenant-id': 'acme' },
credentials: 'include', // for CORS
// Logs (auto-derived to /v1/logs)
logs: {
exporter: 'http/json',
},
// Instrumentations
instrumentations: ['auto'], // document-load, exact click capture, xhr, fetch
fetch: {
ignoreUrls: [/\/healthz?$/, /analytics\.js$/],
propagateContextUrls: [/^https:\/\/api\.myapp\.com/],
},
// Capture
captureUnhandledErrors: true, // window.onerror, unhandledrejection (default: true)
captureConsoleLogs: true, // console.* as OTLP logs (default: false)
includeUserAgent: false, // privacy default
// Initial user context
user: { userId: 'user_123', pseudoId: 'anon_abc', role: 'admin' },
// Dynamic correlation context for future spans and outgoing baggage
contextAttributes: { 'business.id': 'business_123' },
// Debug
logLevel: 'DEBUG',
})
// Update user on login/logout without changing the installation ID
sdk.identify({ userId: 'user_456', pseudoId: 'pseudo_abc' })
sdk.clearUser()
sdk.identify({ userId: 'user_789' })
// Replace context after a business/workspace switch; clear it on logout
sdk.setContextAttributes({ 'business.id': 'business_456' })
sdk.setContextAttributes(null)attributes and contextAttributes have different lifecycles. Use attributes for static resource metadata such as deployment or service facts. Use contextAttributes for mutable correlation values such as the active business, account, or workspace. Each span captures the current dynamic snapshot when it starts, so updates do not retroactively change in-flight or ended spans.
Dynamic context is also propagated as W3C baggage on outgoing Fetch and XHR requests. Treat it only as telemetry correlation data: browser-provided app.installation.id, enduser.id, and enduser.pseudo.id are untrusted claims and must never authorize access or replace server-side identity/session checks. Email and role are never propagated. Keys under enduser.* and app.installation.id are SDK-owned and omitted from generic context.
http/json is the only bundled OTLP transport. Legacy http/protobuf values are ignored at runtime and fall back to JSON.
OTLP JSON protocol contract
The SDK exports traces and logs as OTLP/HTTP JSON protobuf payloads with Content-Type: application/json.
The serializer follows the OTLP JSON mapping used by the OpenTelemetry protocol:
- request envelopes are
ExportTraceServiceRequestandExportLogsServiceRequestJSON objects (resourceSpansandresourceLogs); - JSON field names are lowerCamelCase, for example
startTimeUnixNanoanddroppedAttributesCount; - trace IDs and span IDs are hex strings, not base64;
- 64-bit timestamps and integer attribute values are decimal strings;
- enum fields such as span kind, span status, and log severity are encoded as numbers;
- arbitrary byte attributes are base64 strings;
- spans and logs are grouped by resource and instrumentation scope, including schema URLs when present.
@aient/otel-browser does not bundle protobufjs, @opentelemetry/otlp-transformer, or @opentelemetry/otlp-exporter-base. Protocol-sensitive serializer behavior is covered by test/otlp-json-serializer.test.ts, and dependency regressions are covered by test/package-contract.test.ts.
Environment Variables (via Build Tools)
Browsers don't have runtime environment variables, but you can inject them at build time using your bundler:
Next.js (next.config.mjs):
// Automatically available as process.env.NEXT_PUBLIC_*
// Set in .env or CI environmentVite (vite.config.ts):
export default defineConfig({
define: {
'import.meta.env.VITE_OTEL_ENDPOINT': JSON.stringify(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
'import.meta.env.VITE_COMMIT_SHA': JSON.stringify(process.env.COMMIT_SHA),
}
})Webpack:
new webpack.DefinePlugin({
'process.env.OTEL_ENDPOINT': JSON.stringify(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
})React / Next.js Integration
Use a client-only component at a long-lived application boundary: registering during render can duplicate exporters. Register only in the initialization effect, and update identity in a separate effect without recreating the SDK. shutdown() synchronously detaches instrumentations and global OpenTelemetry registrations before asynchronously draining providers, so an immediate React Strict Mode remount starts a clean generation. Tracer and logger handles cached from the global OpenTelemetry APIs follow the active generation and become no-ops while no @aient/otel-browser SDK is registered.
This package is a browser telemetry bootstrap and must be the sole owner of the page's OpenTelemetry trace, context, propagation, and logs globals. Registration throws without replacing or removing a host application's existing OpenTelemetry globals.
'use client'
import { useEffect, useRef } from 'react'
import { registerOTelBrowser, type BrowserSDK } from '@aient/otel-browser'
export function TelemetryInit({
userId,
pseudoId,
installationId,
}: {
userId?: string | null
pseudoId?: string | null
installationId?: string
}) {
const sdkRef = useRef<BrowserSDK | null>(null)
useEffect(() => {
const sdk = registerOTelBrowser({
publishableKey: process.env.NEXT_PUBLIC_AIENT_PUBLISHABLE_KEY!,
serviceName: process.env.NEXT_PUBLIC_OTEL_SERVICE_NAME ?? 'web',
installationId,
...(process.env.NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT
? { exporterUrl: process.env.NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT }
: {}),
release: {
commit: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA ?? 'dev',
branch: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF ?? 'local',
environment: process.env.NEXT_PUBLIC_VERCEL_ENV ?? 'development',
},
})
sdkRef.current = sdk
return () => {
void sdk.shutdown()
}
}, [])
useEffect(() => {
if (userId || pseudoId) {
sdkRef.current?.identify({
...(userId ? { userId } : {}),
...(pseudoId ? { pseudoId } : {}),
})
} else {
sdkRef.current?.clearUser()
}
}, [userId, pseudoId])
return null
}// app/layout.tsx (App Router)
import { cookies } from 'next/headers'
export default async function RootLayout({ children }) {
const user = await getCurrentUser()
const installationId = (await cookies()).get('app_installation_id')?.value
const pseudonymousUserId = user
? (await cookies()).get('pseudonymous_user_id')?.value
: undefined
return (
<html>
<body>
<TelemetryInit userId={user?.id} pseudoId={pseudonymousUserId} installationId={installationId} />
{children}
</body>
</html>
)
}Browser ↔ server trace context
The SDK does not read a traceparent from HTML metadata. Browser/server
continuity is established when the configured Fetch/XHR instrumentation injects
W3C trace-context headers from an active browser span into an allowed request.
CORS Requirements
Your collector must allow:
Access-Control-Allow-Origin: * (or your origin)
Access-Control-Allow-Headers: content-type, traceparent, baggage, authorization
Access-Control-Allow-Credentials: true (if using credentials)Defaults
| Feature | Default |
|---------|---------|
| Trace exporter | http/json → https://ingest.aient.ai/v1/traces |
| Log exporter | http/json → https://ingest.aient.ai/v1/logs |
| Instrumentations | document-load, one document-capture click span per event, xhr, fetch |
| Error capture | window.onerror, unhandledrejection |
| User agent | NOT included (privacy) |
| Console capture | OFF |
Identity lifecycle
installationId represents one application installation and is emitted as the app.installation.id resource attribute. The generic SDK does not create or persist it. identify() replaces the complete user portion; clearUser() removes it while retaining the installation. getIdentity() returns a clone of the composite snapshot.
sdk.getIdentity() // { installationId: '...' }
sdk.identify({ userId: authenticatedUser.id })
sdk.getIdentity() // { installationId: '...', userId: '...' }
sdk.clearUser()
sdk.getIdentity() // { installationId: '...' }User fields are added to future spans and logs:
| Attribute | Description |
|-----------|-------------|
| enduser.id | Primary user identifier |
| enduser.pseudo.id | Privacy-preserving hash |
| enduser.email | PII, use with caution |
| enduser.role | Permission scope |
setUserContext() and getUserContext() remain compatibility aliases over the same generation-owned user snapshot. Identity calls do not mutate ambient OpenTelemetry context or baggage. The span processor reads at span start, the log processor at emit, and the baggage propagator at request injection.
sdk.identify({ userId: authenticatedUser.id, pseudoId: pseudonymousUserId })The SDK emits both attributes but does not emit an identity-link event. Link events are application-domain telemetry and must define their own privacy, deduplication, and payload contract.
Lifecycle and version capabilities
| Version | Relevant capability |
|---------|---------------------|
| 0.4.1 | User context (setUserContext), but no dynamic context API |
| 0.5.0 | Adds setContextAttributes and getContextAttributes |
| 0.5.1 | Complete instrumentation/global teardown, safe immediate re-registration, blank-error normalization, and ResizeObserver loop filtering |
| 0.6.0 | Exact one-event/one-click capture plus flat identify, clearUser, getIdentity, installation correlation, and untrusted user baggage |
If a runtime method is missing, inspect the installed artifact rather than the workspace manifest:
npm ls @aient/otel-browserThen verify the resolved package in the lockfile and reinstall/rebuild the consuming app. An already-loaded older browser bundle cannot diagnose capabilities introduced by a newer package.
Dynamic Context Attributes
Call sdk.setContextAttributes() when mutable application context changes:
sdk.setContextAttributes({
'business.id': 'business_123',
'workspace.id': 'workspace_456',
})
const snapshot = sdk.getContextAttributes()
sdk.setContextAttributes(null)The setter replaces the whole snapshot rather than merging it. Values are cloned on set and get. Future spans receive the snapshot at span start, while existing spans remain unchanged. Outgoing baggage preserves unrelated entries and replaces SDK-owned dynamic keys.
Click capture
The auto and explicit user selectors install one document capture listener per SDK generation. Every eligible DOM click reaching it creates one synchronous point span named click, even when target handlers later call preventDefault(), return false, or stop propagation. The SDK does not patch application listeners, debounce events, cache fingerprints, or deduplicate exports. Because the span ends at the capture boundary, it is not an active parent around later application handlers.
License
MIT
