npm package: @invinite/otel-web
Description: Lightweight OpenTelemetry initialization for browser SPAs. Sets up tracing with a single `initialize()` call and provides a plugin system for framework-specific instrumentation.
Latest version: 2.1.1

# @invinite/otel-web

Lightweight OpenTelemetry initialization for browser SPAs. Sets up tracing with a single `initialize()` call and provides a plugin system for framework-specific instrumentation.

## Why

Setting up OpenTelemetry in the browser requires wiring together a provider, exporter, resource, and span processors. This library reduces that to a single function call while keeping full control through a plugin system.

## Install

```bash
npm install @invinite/otel-web @opentelemetry/api
```

`@opentelemetry/api` is a peer dependency — you should have exactly one copy in your app.

## Quick Start

```ts
import { initialize } from "@invinite/otel-web";
import { createDocumentLoadPlugin } from "@invinite/otel-web/plugins/document-load";
import { createFetchPlugin } from "@invinite/otel-web/plugins/fetch";

const cleanup = initialize({
  collectorUrl: "https://otel-collector.example.com",
  serviceName: "my-app",
  serviceVersion: "1.2.0",
  environment: "production",
  plugins: [
    createDocumentLoadPlugin(),
    createFetchPlugin({
      propagateToUrls: [/api\.example\.com/],
    }),
  ],
});

// Call cleanup() on app teardown
```

## Configuration

```ts
interface OtelWebConfig {
  /** Base OTLP HTTP URL (e.g. "https://collector.example.com"). Signal paths are appended automatically. */
  collectorUrl: string;
  /** Service name reported in spans */
  serviceName: string;
  /** Service version, added as a resource attribute */
  serviceVersion?: string;
  /** Deployment environment name (e.g. "production", "staging"), added as a resource attribute */
  environment?: string;
  /** Trace sampling rate between 0 and 1. Defaults to 1 (sample everything). */
  sampleRate?: number;
  /** Optional headers sent with every export request */
  headers?: Record<string, string>;
  /** Plugins to activate */
  plugins?: OtelWebPlugin[];
  /** Called on every span start — return attributes to attach */
  getSpanAttributes?: () => SpanAttributes;
  /** Enable experimental logging support (requires @opentelemetry/api-logs) */
  enableLogging?: boolean;
}
```

The `collectorUrl` follows the [OTLP exporter specification](https://opentelemetry.io/docs/specs/otel/protocol/exporter/) — signal paths (`/v1/traces`, `/v1/logs`) are appended automatically. A custom path prefix is preserved: `https://collector.example.com/mycollector` becomes `https://collector.example.com/mycollector/v1/traces`.

### Dynamic Span Attributes

`getSpanAttributes` is called at span creation time, so it can return values that change over the session (e.g., user identity after login):

```ts
initialize({
  collectorUrl: "https://otel-collector.example.com",
  serviceName: "my-app",
  getSpanAttributes: () => ({
    "session.id": sessionId,
    "user.id": auth.currentUser?.id ?? "",
  }),
});
```

## Plugins

### Fetch & XHR

Auto-instruments `fetch()` and `XMLHttpRequest`. Creates a span for every request with method, URL, and status code. OTLP collector URLs are automatically excluded from tracing.

```ts
import { createFetchPlugin } from "@invinite/otel-web/plugins/fetch";

createFetchPlugin({
  // Skip tracing for specific URLs (collector URLs are auto-ignored)
  ignoreUrls: [/\/health/],
  // Propagate W3C trace context headers for distributed tracing
  propagateToUrls: [/api\.example\.com/],
});
```

#### Distributed Tracing with `propagateToUrls`

To connect frontend traces to your backend, use `propagateToUrls` to specify which URLs should receive the W3C `traceparent` header. The fetch plugin automatically injects the header using its own span context — no manual span wrapping needed.

```ts
createFetchPlugin({
  // Inject traceparent into requests matching these patterns
  propagateToUrls: [/\/api\//, /api\.example\.com/],
});
```

Your backend needs to extract the `traceparent` header to continue the trace. Most OpenTelemetry server SDKs do this automatically. The `trace_id` in the header links the frontend and backend spans into a single distributed trace.

### Document Load

Emits spans for page load performance metrics from the Performance API.

```ts
import { createDocumentLoadPlugin } from "@invinite/otel-web/plugins/document-load";

createDocumentLoadPlugin();
```

Spans emitted:

| Span | Attributes |
| --- | --- |
| `document.load` | DNS, connect, TLS, TTFB, response, DOM interactive, DOM content loaded, load event, transfer sizes |
| `first-paint` | Time to first paint |
| `first-contentful-paint` | Time to first contentful paint |
| `largest-contentful-paint` | LCP time, element, size |

#### SSR Trace Context

When server-side rendering, you can connect browser spans to the server trace by injecting W3C trace context meta tags into the HTML:

```html
<meta name="traceparent" content="00-<trace-id>-<span-id>-01" />
<!-- Optional: -->
<meta name="tracestate" content="vendor=value" />
```

The document-load plugin automatically reads these meta tags and uses them as the parent context for all page load spans. No additional configuration is needed.

### TanStack Router

Creates spans for route navigations.

```ts
import { createRouterPlugin } from "@invinite/otel-web/plugins/tanstack-router";

createRouterPlugin(router, {
  // Skip tracing for specific routes
  ignoreRoutes: [/\/health/],
});
```

### TanStack Query

Creates spans for query fetches and mutations with lifecycle tracking.

```ts
import { createQueryPlugin } from "@invinite/otel-web/plugins/tanstack-query";

createQueryPlugin(queryClient, {
  // Skip tracing for specific queries (matches against query hash)
  ignoreQueries: [/health/],
  // Skip tracing for specific mutations (matches against mutation key)
  ignoreMutations: [/analytics/],
});
```

> **Note:** The query plugin traces the query/mutation lifecycle but does not instrument the underlying HTTP requests. If you need distributed tracing (i.e., `traceparent` propagation to your backend), use both the query plugin and the fetch plugin together.

### Error Handler

Captures uncaught errors and unhandled promise rejections as error spans.

```ts
import { createErrorHandlerPlugin } from "@invinite/otel-web/plugins/error-handler";

createErrorHandlerPlugin({
  // Skip tracing for specific errors (matches against error message)
  ignoreErrors: [/ResizeObserver loop/],
});
```

Uses `addEventListener` (not direct assignment) so it won't interfere with existing error handlers.

> **Note:** React and frameworks like TanStack Router catch rendering errors internally via error boundaries, so they never reach `window.onerror`. To report these errors, hook into your framework's error boundary. For example with TanStack Router:
>
> ```tsx
> import { SpanStatusCode, trace } from "@opentelemetry/api";
> import { ErrorComponent, createRootRoute } from "@tanstack/react-router";
> import { useEffect } from "react";
>
> const RootErrorComponent = ({ error }: { error: Error }) => {
>   useEffect(() => {
>     const tracer = trace.getTracer("my-app");
>     const span = tracer.startSpan("error-boundary");
>     span.setAttribute("error.type", error.name);
>     span.setAttribute("error.message", error.message);
>     span.setAttribute("error.stack", error.stack ?? "");
>     span.recordException(error);
>     span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
>     span.end();
>   }, [error]);
>
>   return <ErrorComponent error={error} />;
> };
>
> export const Route = createRootRoute({
>   errorComponent: RootErrorComponent,
> });
> ```

## Logging (Experimental)

> **Note:** OpenTelemetry browser logging is experimental. The API may change in future releases.

To enable logging, set `enableLogging: true` and install the `@opentelemetry/api-logs` peer dependency:

```bash
npm install @opentelemetry/api-logs
```

```ts
const cleanup = initialize({
  collectorUrl: "https://otel-collector.example.com",
  serviceName: "my-app",
  enableLogging: true,
});
```

Logs are exported to `<collectorUrl>/v1/logs`. Once initialized, get a logger anywhere in your app:

```ts
import { logs, SeverityNumber } from "@opentelemetry/api-logs";

const logger = logs.getLogger("my-app");

logger.emit({
  severityNumber: SeverityNumber.INFO,
  severityText: "INFO",
  body: "User completed checkout",
  attributes: { "cart.items": 3 },
});
```

## Custom Spans

After `initialize()` is called, the global `@opentelemetry/api` tracer is available anywhere in your app for manual instrumentation:

```ts
import { trace, SpanStatusCode } from "@opentelemetry/api";

const tracer = trace.getTracer("my-app");

// Track a user action
const span = tracer.startSpan("checkout.submit", {
  attributes: { "cart.items": 3, "cart.total": 99.99 },
});
span.setStatus({ code: SpanStatusCode.OK });
span.end();

// Record an error
const errorSpan = tracer.startSpan("payment.process");
try {
  await processPayment();
  errorSpan.setStatus({ code: SpanStatusCode.OK });
} catch (error) {
  errorSpan.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
  errorSpan.recordException(error);
  throw error;
} finally {
  errorSpan.end();
}
```

These spans are exported to the same endpoint and carry any attributes from `getSpanAttributes`.

## Custom Plugins

```ts
import type { OtelWebPlugin } from "@invinite/otel-web";

const myPlugin: OtelWebPlugin = {
  setup(tracer) {
    // Subscribe to events, start spans
  },
  teardown() {
    // Unsubscribe, clean up
  },
};
```

## License

MIT