Telemetry
Every app needs telemetry — identifying users, tracking events, capturing exceptions — but you don't want Sentry.captureException or posthog.capture calls scattered across the codebase. If you ever switch providers, you'd have to hunt down every call site. The telemetry module solves this with a minimal interface that isolates the app from the actual provider (Sentry, PostHog, Datadog, custom). You program against the interface; the provider is injected once, at the root.
Concrete adapters for the most common providers ship ready-made in the SDK; none of them is a peer dep — the caller injects the instance of the external SDK, so you only install what you use.
Why inject the instance instead of declaring a peer dep
Apps that already initialize Sentry at startup (DSN, sample rate,
integrations) want to reuse that instance — not one created by the SDK. And
apps that don't use Sentry shouldn't pay for it in the bundle. That's why
each create<Provider>Adapter receives the ready instance in its options
({ sentry: Sentry }, { posthog }).
Interface
import type { TelemetryAdapter, TelemetryEvent, TelemetryUser } from "tempest-react-sdk";
interface TelemetryAdapter {
init?: () => void | Promise<void>;
identify: (user: TelemetryUser | null) => void;
track: (event: TelemetryEvent) => void;
captureException: (error: unknown, context?: Record<string, unknown>) => void;
flush?: () => Promise<void> | void;
}
interface TelemetryUser {
id?: string;
email?: string;
name?: string;
traits?: Record<string, unknown>;
}
interface TelemetryEvent {
name: string;
properties?: Record<string, unknown>;
}
Provider
import { TelemetryProvider, consoleTelemetryAdapter } from "tempest-react-sdk";
<TelemetryProvider adapter={consoleTelemetryAdapter}>{children}</TelemetryProvider>;
consoleTelemetryAdapter is the default for dev/test — it logs every call to the console.
TelemetryProvider invokes adapter.init?.() on mount and adapter.flush?.() on unmount.
Usage in components
import { useTelemetry } from "tempest-react-sdk";
const telemetry = useTelemetry();
telemetry?.track({ name: "alo_purchased", properties: { aloId, valueBRL: 990 } });
useTelemetry() returns null when no provider is mounted — the UI does not break in tests.
Always use optional chaining
useTelemetry() returns null outside a <TelemetryProvider>. Always call
it with ?. (telemetry?.track(...)) — that way the call sites keep working
in unit tests and in trees that don't mount the provider, without blowing up
with "cannot read property of null".
Complete setup — adapter + provider + identify + track
A real app initializes the adapter at the root, identifies the user on login, and tracks events on actions. Complete, copy-pasteable skeleton:
// telemetry.tsx
import posthog from "posthog-js";
import { createPostHogTelemetryAdapter, TelemetryProvider } from "tempest-react-sdk";
import type { ReactNode } from "react";
export const telemetryAdapter = createPostHogTelemetryAdapter({
posthog,
init: {
apiKey: import.meta.env.VITE_POSTHOG_KEY,
options: { api_host: "https://us.i.posthog.com" },
},
});
export function AppTelemetry({ children }: { children: ReactNode }) {
return <TelemetryProvider adapter={telemetryAdapter}>{children}</TelemetryProvider>;
}
// useSessionTelemetry.ts — identify/reset when the session changes
import { useEffect } from "react";
import { useTelemetry } from "tempest-react-sdk";
import { useAuthStore } from "./auth-store";
export function useSessionTelemetry() {
const telemetry = useTelemetry();
const user = useAuthStore((s) => s.user);
useEffect(() => {
if (user) {
telemetry?.identify({ id: user.id, email: user.email, name: user.name });
} else {
telemetry?.identify(null); // logout → reset
}
}, [telemetry, user]);
}
// CheckoutButton.tsx — track a business event
import { useTelemetry, Button } from "tempest-react-sdk";
export function CheckoutButton({ aloId, valueBRL }: { aloId: string; valueBRL: number }) {
const telemetry = useTelemetry();
return (
<Button
onClick={() => {
telemetry?.track({ name: "alo_purchased", properties: { aloId, valueBRL } });
}}
>
Buy
</Button>
);
}
Sentry adapter
Wraps @sentry/browser (or @sentry/react) — the Sentry SDK is passed in by the caller, it does not become a peer dep.
import * as Sentry from "@sentry/browser";
import { createSentryTelemetryAdapter, TelemetryProvider } from "tempest-react-sdk";
const adapter = createSentryTelemetryAdapter({
sentry: Sentry,
initOptions: {
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
tracesSampleRate: 0.1,
},
flushTimeout: 2000, // default
breadcrumbCategory: "app", // default
});
<TelemetryProvider adapter={adapter}>{children}</TelemetryProvider>;
Mapping:
TelemetryAdapter |
@sentry/browser |
|---|---|
init() |
Sentry.init(initOptions) (only when initOptions is passed) |
identify(user) |
Sentry.setUser({id, email, username, ...traits}) |
identify(null) |
Sentry.setUser(null) |
track({name, properties}) |
Sentry.addBreadcrumb({category, message, level:"info", data}) |
captureException(err, ctx) |
Sentry.captureException(err, { extra: ctx }) |
flush() |
Sentry.flush(flushTimeout) |
The SentryLike type is exported so you can mock it in tests:
import type { SentryLike } from "tempest-react-sdk";
const fakeSentry: SentryLike = {
setUser: vi.fn(),
addBreadcrumb: vi.fn(),
captureException: vi.fn(),
};
PostHog adapter
Wraps posthog-js.
import posthog from "posthog-js";
import { createPostHogTelemetryAdapter, TelemetryProvider } from "tempest-react-sdk";
const adapter = createPostHogTelemetryAdapter({
posthog,
init: {
apiKey: import.meta.env.VITE_POSTHOG_KEY,
options: { api_host: "https://us.i.posthog.com" },
},
});
<TelemetryProvider adapter={adapter}>{children}</TelemetryProvider>;
Mapping:
TelemetryAdapter |
posthog-js |
|---|---|
init() |
posthog.init(apiKey, options) (only when init is passed) |
identify({id, ...}) |
posthog.identify(id, { email, name, ...traits }) (skip if id is missing) |
identify(null) |
posthog.reset() |
track({name, properties}) |
posthog.capture(name, properties) |
captureException(err, ctx) |
posthog.captureException(err, ctx) when available, fallback posthog.capture("$exception", {...}) |
SentryLike / PostHogLike are subsets, not the whole SDKs
Each adapter declares only the methods it uses (setUser, addBreadcrumb,
capture…). That gives you a tiny target to mock in tests — you assemble an
object with 3 vi.fn() instead of stubbing the full SDK — and keeps the
adapter resilient to API changes that don't touch that subset.
Custom adapter
For Datadog, Amplitude, Mixpanel — write ~20 lines:
import type { TelemetryAdapter } from "tempest-react-sdk";
import { datadogRum } from "@datadog/browser-rum";
export const datadogAdapter: TelemetryAdapter = {
init: () => datadogRum.init({ clientToken: "...", applicationId: "...", site: "datadoghq.com" }),
identify: (user) =>
user ? datadogRum.setUser({ id: user.id, email: user.email }) : datadogRum.clearUser(),
track: ({ name, properties }) => datadogRum.addAction(name, properties),
captureException: (error, context) => datadogRum.addError(error, context),
};
Integration with ErrorBoundary
const telemetry = useTelemetry();
<ErrorBoundary onError={(err, info) => telemetry?.captureException(err, info)}>
{children}
</ErrorBoundary>;
Recap
- Program against
TelemetryAdapter, not the provider's SDK — switching provider = changing one line at the root. TelemetryProviderinjects the adapter; callsiniton mount,flushon unmount.useTelemetry()can benull— alwaystelemetry?.track(...)with optional chaining.- Adapters inject the instance (
{ sentry },{ posthog }) — never a peer dep. - A custom adapter is ~20 lines mapping 4 methods.
See also
- Error Boundary —
onError→captureException - Logger — local structured logs
- Feature Flags — adapters follow the exact same injection pattern