@heystack/otel 0.17.0 → 0.18.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
@@ -693,3 +693,23 @@ trackers on logout; restart replay to avoid linking different people. Replay
693
693
  sampling remains independent. Use a dedicated browser ingest configuration,
694
694
  never expose the backend secret. Native clients may emit the documented event
695
695
  attributes through existing log APIs.
696
+
697
+
698
+ ### Product instrumentation 0.18.0
699
+
700
+ `createBrowserProductAnalytics` provides a per-browser identity/session facade:
701
+ `identify` rotates on account changes; `reset` clears identity and returns a fresh
702
+ session UUID. Restart replay with that UUID. Servers continue to use immutable
703
+ `createProductAnalytics().withContext()` per request.
704
+
705
+ Events accept `eventId` (stable on retries) and `attemptId` (shared by started and
706
+ terminal outcomes). Optional `eventDefinitions` validates declared names and
707
+ required context. `allowUserProfiles` enables explicitly supplied
708
+ `userProfile: { displayName, source: "clerk" | "app" }`; `allowNetworkContext`
709
+ enables original-client IP/geography supplied via `network`. Both are off by
710
+ default. Heystack does not resolve monitored users through its own Clerk directory.
711
+
712
+ Emit `screen.viewed` for actual navigation, with stable `surface` and `sessionId`.
713
+ Emit feature attempts with `feature`, `attemptId` and `outcome`. Keep the same
714
+ user/session/attempt context across backend calls. See the [exact telemetry
715
+ contract and verification checklist](https://heystack.dev/docs/product-activity).
package/dist/product.d.ts CHANGED
@@ -2,6 +2,19 @@ import { type HeystackOptions } from "./core.js";
2
2
  export interface ActivityContext {
3
3
  /** Stable pseudonymous identifiers; never email addresses or credentials. */
4
4
  userId?: string;
5
+ /** Optional display context, exported only with allowUserProfiles. */
6
+ userProfile?: {
7
+ displayName: string;
8
+ source: "app" | "clerk";
9
+ };
10
+ /** Observed client network context, never inferred from an exporter server. */
11
+ network?: {
12
+ ip?: string;
13
+ country?: string;
14
+ region?: string;
15
+ city?: string;
16
+ source?: string;
17
+ };
5
18
  sessionId?: string;
6
19
  feature?: string;
7
20
  /** Stable screen/route name, not a URL containing customer data. */
@@ -11,12 +24,20 @@ export interface ActivityContext {
11
24
  }
12
25
  export interface ProductEvent extends ActivityContext {
13
26
  name: string;
27
+ eventId?: string;
28
+ attemptId?: string;
14
29
  action?: string;
15
30
  outcome?: "started" | "succeeded" | "failed" | "cancelled";
16
31
  properties?: Record<string, string | number | boolean>;
17
32
  }
18
33
  export interface ProductAnalyticsOptions extends HeystackOptions {
19
34
  environment?: string;
35
+ allowUserProfiles?: boolean;
36
+ allowNetworkContext?: boolean;
37
+ /** Optional versioned event catalogue: rejects unlisted names or missing context. */
38
+ eventDefinitions?: Readonly<Record<string, {
39
+ required?: readonly ("feature" | "surface" | "sessionId" | "userId" | "attemptId" | "outcome")[];
40
+ }>>;
20
41
  /** Supply an original, uninstrumented fetch if browser fetch is already patched. */
21
42
  fetch?: typeof fetch;
22
43
  /** Explicitly allow metadata keys. Unlisted properties are discarded. */
@@ -39,3 +60,18 @@ export interface ProductAnalytics extends ActivityTracker {
39
60
  * Bounded, best-effort collection; never auto-captures UI text or form contents.
40
61
  * The return value means queued, not delivered or verified business success. */
41
62
  export declare function createProductAnalytics(options: ProductAnalyticsOptions): ProductAnalytics;
63
+ /** Per-browser-session facade. Do not share this mutable identity facade on servers. */
64
+ export declare function createBrowserProductAnalytics(options: ProductAnalyticsOptions & {
65
+ sessionId?: string;
66
+ }): {
67
+ readonly sessionId: string;
68
+ /** Starts a fresh session on identity changes. Reconfigure replay with returned ID. */
69
+ identify(userId: string, profile?: ActivityContext["userProfile"]): string;
70
+ /** Logout boundary: clear identity and rotate session. Reconfigure replay too. */
71
+ reset(): string;
72
+ track(event: ProductEvent): boolean;
73
+ screen(surface: string, event?: Omit<ProductEvent, "name" | "surface">): boolean;
74
+ feature(feature: string, event?: Omit<ProductEvent, "name" | "feature">): boolean;
75
+ flush: () => Promise<boolean>;
76
+ shutdown: () => Promise<boolean>;
77
+ };
package/dist/product.js CHANGED
@@ -26,16 +26,30 @@ export function createProductAnalytics(options) {
26
26
  track(event) {
27
27
  if (stopped || queue.length >= MAX_QUEUE || !NAME.test(event.name))
28
28
  return false;
29
- const active = trace.getSpan(context.active())?.spanContext();
30
29
  const ctx = { ...bound, ...event };
30
+ if (options.eventDefinitions && (!Object.hasOwn(options.eventDefinitions, event.name) ||
31
+ options.eventDefinitions[event.name]?.required?.some(key => !text(ctx[key]))))
32
+ return false;
33
+ const active = trace.getSpan(context.active())?.spanContext();
31
34
  const attrs = {
32
- "event.name": event.name, "event.domain": "product", "event.id": crypto.randomUUID(),
35
+ "event.name": event.name, "event.domain": "product", "event.id": text(event.eventId) || crypto.randomUUID(), "heystack.schema.version": "1",
33
36
  };
34
37
  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 })) {
38
+ "heystack.attempt.id": event.attemptId, "heystack.feature": ctx.feature, "heystack.surface": ctx.surface, "heystack.action": event.action, "heystack.outcome": event.outcome })) {
36
39
  if (text(v))
37
40
  attrs[key] = text(v);
38
41
  }
42
+ if (options.allowUserProfiles && text(ctx.userId) && ctx.userProfile) {
43
+ attrs["heystack.user.display_name"] = text(ctx.userProfile.displayName);
44
+ attrs["heystack.user.source"] = ctx.userProfile.source === "clerk" ? "clerk" : "app";
45
+ }
46
+ if (options.allowNetworkContext && ctx.network) {
47
+ for (const [key, v] of Object.entries({ "client.address": ctx.network.ip, "geo.country.code": ctx.network.country,
48
+ "geo.locality.region": ctx.network.region, "geo.locality.name": ctx.network.city, "heystack.geo.source": ctx.network.source })) {
49
+ if (text(v))
50
+ attrs[key] = text(v);
51
+ }
52
+ }
39
53
  for (const key of allowed) {
40
54
  const v = event.properties?.[key];
41
55
  if (typeof v === "string")
@@ -95,3 +109,27 @@ export function createProductAnalytics(options) {
95
109
  return { ...tracker({}), flush, async shutdown() { stopped = true; if (timer)
96
110
  clearInterval(timer); return flush(); } };
97
111
  }
112
+ /** Per-browser-session facade. Do not share this mutable identity facade on servers. */
113
+ export function createBrowserProductAnalytics(options) {
114
+ const client = createProductAnalytics(options);
115
+ let sessionId = options.sessionId ?? crypto.randomUUID();
116
+ let user = {};
117
+ const bound = () => client.withContext({ ...user, sessionId });
118
+ return {
119
+ get sessionId() { return sessionId; },
120
+ /** Starts a fresh session on identity changes. Reconfigure replay with returned ID. */
121
+ identify(userId, profile) {
122
+ if (user.userId !== userId)
123
+ sessionId = crypto.randomUUID();
124
+ user = { userId, userProfile: profile };
125
+ return sessionId;
126
+ },
127
+ /** Logout boundary: clear identity and rotate session. Reconfigure replay too. */
128
+ reset() { user = {}; sessionId = crypto.randomUUID(); return sessionId; },
129
+ track(event) { return bound().track({ ...event, userId: user.userId, userProfile: user.userProfile, sessionId }); },
130
+ screen(surface, event = {}) { return this.track({ ...event, name: "screen.viewed", surface }); },
131
+ feature(feature, event = {}) { return this.track({ ...event, name: "feature.used", feature }); },
132
+ flush: client.flush,
133
+ shutdown: client.shutdown,
134
+ };
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heystack/otel",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Runtime-aware OpenTelemetry tracing that exports to Heystack (Node, Next.js, Workers).",
5
5
  "license": "MIT",
6
6
  "type": "module",