@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 +20 -0
- package/dist/product.d.ts +36 -0
- package/dist/product.js +41 -3
- package/package.json +1 -1
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
|
+
}
|