@heystack/otel 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -659,3 +659,37 @@ claude mcp add --transport http heystack https://mcp.heystack.dev/mcp
659
659
  ```
660
660
 
661
661
  One-click links for Cursor and VS Code, commands for Codex/Gemini CLI, personal access tokens for CI, and the full tool reference are in the docs: **https://heystack.dev/docs/mcp**.
662
+
663
+
664
+ ## Product activity (0.17.0)
665
+
666
+ Use `createProductAnalytics` from `@heystack/otel/product` alongside the runtime
667
+ instrumentation. `track({ name, feature?, surface?, action?, outcome?, userId?,
668
+ sessionId?, traceId?, spanId?, properties? })` queues an explicit event.
669
+ `withContext(context)` returns an immutable request/session tracker;
670
+ `feature(name, options?)` and `screen(name, options?)` are convenience methods.
671
+ There is no mutable global user identity. Read the [full guide](https://heystack.dev/docs/product-activity).
672
+
673
+ ```ts
674
+ import { createProductAnalytics } from "@heystack/otel/product";
675
+ const activity = createProductAnalytics({ apiKey, service: "api", version: "2.4.0", allowedProperties: ["plan"] });
676
+ activity.withContext({ userId, sessionId, feature: "signup" }).track({
677
+ name: "signup.completed", outcome: "succeeded", properties: { plan: "starter" },
678
+ });
679
+ await activity.flush(); // Workers: ctx.waitUntil(activity.flush())
680
+ ```
681
+
682
+ Only allowlisted metadata is exported. Queue: 200 events; batches: 50;
683
+ properties: 20 keys, 256-character strings. `track` reports queued, `flush`
684
+ reports HTTP acceptance, not durable query visibility. Failed batches remain
685
+ bounded for retry; counts may include duplicates. No automatic click/form/text
686
+ capture. The browser-only `flushIntervalMs` timer is opt-in (minimum 5s);
687
+ servers have no background timer. Call `shutdown()` to stop and flush.
688
+
689
+ `instrumentWeb` now accepts `sessionId`, a UUID shared with product activity.
690
+ Capture product collection's original fetch before calling `instrumentWeb`,
691
+ or supply an original `fetch`. Rotate session UUID and discard bound user
692
+ trackers on logout; restart replay to avoid linking different people. Replay
693
+ sampling remains independent. Use a dedicated browser ingest configuration,
694
+ never expose the backend secret. Native clients may emit the documented event
695
+ attributes through existing log APIs.
@@ -0,0 +1,41 @@
1
+ import { type HeystackOptions } from "./core.js";
2
+ export interface ActivityContext {
3
+ /** Stable pseudonymous identifiers; never email addresses or credentials. */
4
+ userId?: string;
5
+ sessionId?: string;
6
+ feature?: string;
7
+ /** Stable screen/route name, not a URL containing customer data. */
8
+ surface?: string;
9
+ traceId?: string;
10
+ spanId?: string;
11
+ }
12
+ export interface ProductEvent extends ActivityContext {
13
+ name: string;
14
+ action?: string;
15
+ outcome?: "started" | "succeeded" | "failed" | "cancelled";
16
+ properties?: Record<string, string | number | boolean>;
17
+ }
18
+ export interface ProductAnalyticsOptions extends HeystackOptions {
19
+ environment?: string;
20
+ /** Supply an original, uninstrumented fetch if browser fetch is already patched. */
21
+ fetch?: typeof fetch;
22
+ /** Explicitly allow metadata keys. Unlisted properties are discarded. */
23
+ allowedProperties?: readonly string[];
24
+ /** Opt-in browser flush timer. Default: no background work; await flush on servers. */
25
+ flushIntervalMs?: number;
26
+ }
27
+ export interface ActivityTracker {
28
+ track(event: ProductEvent): boolean;
29
+ feature(name: string, event?: Omit<ProductEvent, "name" | "feature">): boolean;
30
+ screen(name: string, event?: Omit<ProductEvent, "name" | "surface">): boolean;
31
+ /** Immutable binding. Create one per request/session; never store request identity globally. */
32
+ withContext(context: ActivityContext): ActivityTracker;
33
+ }
34
+ export interface ProductAnalytics extends ActivityTracker {
35
+ flush(): Promise<boolean>;
36
+ shutdown(): Promise<boolean>;
37
+ }
38
+ /** Explicit product activity over the existing OTLP logs ingest path.
39
+ * Bounded, best-effort collection; never auto-captures UI text or form contents.
40
+ * The return value means queued, not delivered or verified business success. */
41
+ export declare function createProductAnalytics(options: ProductAnalyticsOptions): ProductAnalytics;
@@ -0,0 +1,97 @@
1
+ import { suppressTracing } from "@opentelemetry/core";
2
+ import { context, trace, isSpanContextValid } from "@opentelemetry/api";
3
+ import { DEFAULT_ENDPOINT, releaseResourceAttributes } from "./core.js";
4
+ const MAX_QUEUE = 200;
5
+ const MAX_BATCH = 50;
6
+ const NAME = /^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/;
7
+ const SENSITIVE = /password|secret|token|authorization|cookie|email|phone|credit|card|body|prompt|content/i;
8
+ const text = (v, max = 256) => typeof v === "string" ? v.trim().slice(0, max) : "";
9
+ const id = (v, length) => typeof v === "string" && new RegExp(`^[a-f0-9]{${length}}$`, "i").test(v) && !/^0+$/.test(v) ? v.toLowerCase() : "";
10
+ /** Explicit product activity over the existing OTLP logs ingest path.
11
+ * Bounded, best-effort collection; never auto-captures UI text or form contents.
12
+ * The return value means queued, not delivered or verified business success. */
13
+ export function createProductAnalytics(options) {
14
+ const send = options.fetch ?? globalThis.fetch.bind(globalThis);
15
+ const endpoint = `${(options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "")}/v1/logs`;
16
+ const resource = { "service.name": options.service, ...releaseResourceAttributes(options),
17
+ ...(options.environment ? { "deployment.environment": text(options.environment) } : {}) };
18
+ const allowed = new Set((options.allowedProperties ?? []).filter(k => NAME.test(k) && !SENSITIVE.test(k)).slice(0, 20));
19
+ const kv = (key, value) => ({ key, value: typeof value === "boolean" ? { boolValue: value } : typeof value === "number" ? { doubleValue: value } : { stringValue: value } });
20
+ let queue = [];
21
+ let sequence = 0;
22
+ let stopped = false;
23
+ let inFlight;
24
+ let timer;
25
+ const tracker = (bound) => ({
26
+ track(event) {
27
+ if (stopped || queue.length >= MAX_QUEUE || !NAME.test(event.name))
28
+ return false;
29
+ const active = trace.getSpan(context.active())?.spanContext();
30
+ const ctx = { ...bound, ...event };
31
+ const attrs = {
32
+ "event.name": event.name, "event.domain": "product", "event.id": crypto.randomUUID(),
33
+ };
34
+ for (const [key, v] of Object.entries({ "enduser.id": ctx.userId, "session.id": ctx.sessionId,
35
+ "heystack.feature": ctx.feature, "heystack.surface": ctx.surface, "heystack.action": event.action, "heystack.outcome": event.outcome })) {
36
+ if (text(v))
37
+ attrs[key] = text(v);
38
+ }
39
+ for (const key of allowed) {
40
+ const v = event.properties?.[key];
41
+ if (typeof v === "string")
42
+ attrs[`heystack.properties.${key}`] = v.slice(0, 256);
43
+ else if (typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v)))
44
+ attrs[`heystack.properties.${key}`] = v;
45
+ }
46
+ const traceId = ctx.traceId !== undefined ? id(ctx.traceId, 32) : (active && isSpanContextValid(active) ? active.traceId : "");
47
+ const spanId = ctx.traceId ? id(ctx.spanId, 16) : active && isSpanContextValid(active) ? active.spanId : "";
48
+ queue.push({ sequence: ++sequence, record: { timeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
49
+ severityText: event.outcome === "failed" ? "ERROR" : "INFO", severityNumber: event.outcome === "failed" ? 17 : 9,
50
+ body: { stringValue: event.name }, attributes: Object.entries(attrs).map(([k, v]) => kv(k, v)),
51
+ ...(traceId ? { traceId, ...(spanId ? { spanId } : {}) } : {}) } });
52
+ return true;
53
+ },
54
+ feature(name, event = {}) { return this.track({ ...event, name: "feature.used", feature: name }); },
55
+ screen(name, event = {}) { return this.track({ ...event, name: "screen.viewed", surface: name }); },
56
+ withContext(next) { return tracker({ ...bound, ...next }); },
57
+ });
58
+ const flushThrough = (target) => {
59
+ // Every caller waits for its own sequence watermark, including events queued
60
+ // during an earlier export. New producers cannot extend this caller forever.
61
+ if (inFlight)
62
+ return inFlight.then(ok => ok ? flushThrough(target) : false);
63
+ const run = async () => {
64
+ while (queue.length && queue[0].sequence <= target) {
65
+ const size = Math.min(MAX_BATCH, queue.filter(item => item.sequence <= target).length);
66
+ const batch = queue.splice(0, size);
67
+ const records = batch.map(item => item.record);
68
+ const controller = new AbortController();
69
+ const timeout = setTimeout(() => controller.abort(), 5000);
70
+ try {
71
+ const response = await context.with(suppressTracing(context.active()), () => send(endpoint, { method: "POST", signal: controller.signal,
72
+ headers: { "content-type": "application/json", authorization: `Bearer ${options.apiKey}` },
73
+ body: JSON.stringify({ resourceLogs: [{ resource: { attributes: Object.entries(resource).map(([k, v]) => kv(k, v)) },
74
+ scopeLogs: [{ scope: { name: "@heystack/otel/product", version: "1" }, logRecords: records }] }] }) }));
75
+ if (!response.ok)
76
+ throw new Error("export failed");
77
+ }
78
+ catch {
79
+ queue = [...batch, ...queue].slice(0, MAX_QUEUE);
80
+ return false;
81
+ }
82
+ finally {
83
+ clearTimeout(timeout);
84
+ }
85
+ }
86
+ return true;
87
+ };
88
+ inFlight = run().finally(() => { inFlight = undefined; });
89
+ return inFlight;
90
+ };
91
+ const flush = () => flushThrough(sequence);
92
+ if (typeof window !== "undefined" && options.flushIntervalMs !== undefined) {
93
+ timer = setInterval(() => { void flush(); }, Math.max(5000, Number.isFinite(options.flushIntervalMs) ? options.flushIntervalMs : 5000));
94
+ }
95
+ return { ...tracker({}), flush, async shutdown() { stopped = true; if (timer)
96
+ clearInterval(timer); return flush(); } };
97
+ }
package/dist/web.d.ts CHANGED
@@ -85,6 +85,8 @@ export interface InstrumentWebOptions {
85
85
  * resource of exported browser logs for commit-level attribution.
86
86
  */
87
87
  build?: string;
88
+ /** Optional shared UUID for product events, browser errors and replay. Rotate on logout. */
89
+ sessionId?: string;
88
90
  /** Optional app-supplied user identifier stamped on the session. */
89
91
  userId?: string;
90
92
  /** Local overrides; by default sampling + masking come from server config. */
@@ -144,6 +146,8 @@ export declare function tracingActive(opts: Pick<InstrumentWebOptions, "tracing"
144
146
  * (`cfg.errors_enabled`). **Fail-open**: an absent/`true` flag counts as enabled.
145
147
  */
146
148
  export declare function errorCaptureActive(opts: Pick<InstrumentWebOptions, "errors">, cfg: Pick<RecorderConfig, "errors_enabled">): boolean;
149
+ /** A caller-owned session UUID allows product activity to correlate with replay. */
150
+ export declare function resolveWebSessionId(candidate?: string): string;
147
151
  /** Entry point: fetch config, decide sampling, start rrweb, stream chunks.
148
152
  * Returns a stop() function. Safe to call in any browser; no-ops on the server. */
149
153
  export declare function instrumentWeb(opts: InstrumentWebOptions): Promise<() => void>;
package/dist/web.js CHANGED
@@ -100,6 +100,14 @@ export function tracingActive(opts, cfg) {
100
100
  export function errorCaptureActive(opts, cfg) {
101
101
  return opts.errors !== false && cfg.errors_enabled !== false;
102
102
  }
103
+ /** A caller-owned session UUID allows product activity to correlate with replay. */
104
+ export function resolveWebSessionId(candidate) {
105
+ if (candidate === undefined)
106
+ return crypto.randomUUID();
107
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate))
108
+ throw new Error("sessionId must be a UUID");
109
+ return candidate;
110
+ }
103
111
  /** Entry point: fetch config, decide sampling, start rrweb, stream chunks.
104
112
  * Returns a stop() function. Safe to call in any browser; no-ops on the server. */
105
113
  export async function instrumentWeb(opts) {
@@ -125,7 +133,7 @@ export async function instrumentWeb(opts) {
125
133
  // One session id for the whole run — shared by errors, console logs AND replay
126
134
  // (created here, before the replay sampling gate, so errors correlate even when
127
135
  // replay isn't sampled; when it is, they share this id → error ↔ replay linking).
128
- const sessionId = crypto.randomUUID();
136
+ const sessionId = resolveWebSessionId(opts.sessionId);
129
137
  // service.version / service.build for release attribution on exported browser logs.
130
138
  const releaseAttrs = releaseResourceAttributes({ version: opts.version, build: opts.build });
131
139
  // 2. Browser distributed tracing (opt-in) — INDEPENDENT of replay sampling. Emits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heystack/otel",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Runtime-aware OpenTelemetry tracing that exports to Heystack (Node, Next.js, Workers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,6 +30,10 @@
30
30
  "./web": {
31
31
  "types": "./dist/web.d.ts",
32
32
  "import": "./dist/web.js"
33
+ },
34
+ "./product": {
35
+ "types": "./dist/product.d.ts",
36
+ "import": "./dist/product.js"
33
37
  }
34
38
  },
35
39
  "files": [