@mastra/posthog 1.2.0 → 1.2.1

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/dist/index.js CHANGED
@@ -1,434 +1,416 @@
1
- import { SpanType } from '@mastra/core/observability';
2
- import { TrackingExporter } from '@mastra/observability';
3
- import { PostHog } from 'posthog-node';
4
-
5
- // src/tracing.ts
1
+ import { SpanType } from "@mastra/core/observability";
2
+ import { TrackingExporter } from "@mastra/observability";
3
+ import { PostHog } from "posthog-node";
4
+ //#region src/tracing.ts
5
+ /**
6
+ * Formats UsageStats to PostHog's expected property format.
7
+ *
8
+ * Pass through gross input token counts with cache fields as subsets.
9
+ * PostHog subtracts cache tokens when computing costs for non-Anthropic
10
+ * providers and detects Anthropic-style exclusive reporting on its own.
11
+ *
12
+ * @param usage - The UsageStats from span attributes
13
+ * @returns PostHog-formatted usage properties
14
+ */
6
15
  function formatUsageMetrics(usage) {
7
- if (!usage) return {};
8
- const props = {};
9
- if (usage.inputTokens !== void 0) {
10
- props.$ai_input_tokens = usage.inputTokens;
11
- }
12
- if (usage.inputDetails?.cacheRead !== void 0) {
13
- props.$ai_cache_read_input_tokens = usage.inputDetails.cacheRead;
14
- }
15
- if (usage.inputDetails?.cacheWrite !== void 0) {
16
- props.$ai_cache_creation_input_tokens = usage.inputDetails.cacheWrite;
17
- }
18
- if (usage.outputTokens !== void 0) {
19
- props.$ai_output_tokens = usage.outputTokens;
20
- }
21
- return props;
16
+ if (!usage) return {};
17
+ const props = {};
18
+ if (usage.inputTokens !== void 0) props.$ai_input_tokens = usage.inputTokens;
19
+ if (usage.inputDetails?.cacheRead !== void 0) props.$ai_cache_read_input_tokens = usage.inputDetails.cacheRead;
20
+ if (usage.inputDetails?.cacheWrite !== void 0) props.$ai_cache_creation_input_tokens = usage.inputDetails.cacheWrite;
21
+ if (usage.outputTokens !== void 0) props.$ai_output_tokens = usage.outputTokens;
22
+ return props;
22
23
  }
23
- var DISTINCT_ID = "distinctId";
24
- var PosthogExporter = class _PosthogExporter extends TrackingExporter {
25
- name = "posthog";
26
- #client;
27
- static SERVERLESS_FLUSH_AT = 10;
28
- static SERVERLESS_FLUSH_INTERVAL = 2e3;
29
- static DEFAULT_FLUSH_AT = 20;
30
- static DEFAULT_FLUSH_INTERVAL = 1e4;
31
- constructor(config = {}) {
32
- const apiKey = config.apiKey ?? process.env.POSTHOG_API_KEY;
33
- super({ ...config, apiKey });
34
- if (!apiKey) {
35
- this.setDisabled("Missing required API key. Set POSTHOG_API_KEY environment variable or pass apiKey in config.");
36
- return;
37
- }
38
- const clientConfig = this.buildClientConfig(this.config);
39
- this.#client = new PostHog(apiKey, clientConfig);
40
- const message = config.serverless ?? false ? "PostHog exporter initialized in serverless mode" : "PostHog exporter initialized";
41
- this.logger.debug(message, config);
42
- }
43
- buildClientConfig(config) {
44
- const isServerless = config.serverless ?? false;
45
- const flushAt = config.flushAt ?? (isServerless ? _PosthogExporter.SERVERLESS_FLUSH_AT : _PosthogExporter.DEFAULT_FLUSH_AT);
46
- const flushInterval = config.flushInterval ?? (isServerless ? _PosthogExporter.SERVERLESS_FLUSH_INTERVAL : _PosthogExporter.DEFAULT_FLUSH_INTERVAL);
47
- const host = config.host || process.env.POSTHOG_HOST || "https://us.i.posthog.com";
48
- if (!config.host && !process.env.POSTHOG_HOST) {
49
- this.logger.info(
50
- 'No PostHog host specified, using US default (https://us.i.posthog.com). For EU region, set `host: "https://eu.i.posthog.com"` in config or POSTHOG_HOST env var. For self-hosted, provide your instance URL.'
51
- );
52
- }
53
- return {
54
- host,
55
- flushAt,
56
- flushInterval,
57
- privacyMode: config.enablePrivacyMode
58
- };
59
- }
60
- skipBuildRootTask = true;
61
- async _buildRoot(_args) {
62
- throw new Error("Method not implemented.");
63
- }
64
- skipCachingEventSpans = true;
65
- async _buildEvent(args) {
66
- const { span, traceData } = args;
67
- const eventName = this.mapToPostHogEvent(span.type);
68
- const distinctId = this.getDistinctId(span, traceData);
69
- const properties = this.buildEventProperties(span, 0);
70
- this.#client?.capture(
71
- this.withGroups({
72
- distinctId,
73
- event: eventName,
74
- properties,
75
- timestamp: span.endTime ? new Date(span.endTime) : /* @__PURE__ */ new Date()
76
- })
77
- );
78
- return true;
79
- }
80
- async _buildSpan(args) {
81
- const { span, traceData } = args;
82
- if (!traceData.hasExtraValue(DISTINCT_ID)) {
83
- const userId = span.metadata?.userId;
84
- if (userId) {
85
- traceData.setExtraValue(DISTINCT_ID, String(userId));
86
- }
87
- }
88
- return span;
89
- }
90
- skipSpanUpdateEvents = true;
91
- _updateSpan(_args) {
92
- throw new Error("Method not implemented.");
93
- }
94
- async _finishSpan(args) {
95
- const { span, traceData } = args;
96
- const cachedSpan = traceData.getSpan({ spanId: span.id });
97
- const mergedSpan = !span.input && cachedSpan?.input ? { ...span, input: cachedSpan.input } : span;
98
- const eventMessage = this.buildEventMessage({ span: mergedSpan, traceData });
99
- this.#client?.capture(this.withGroups(eventMessage));
100
- }
101
- async _abortSpan(args) {
102
- const { span, reason, traceData } = args;
103
- span.errorInfo = reason;
104
- const eventMessage = this.buildEventMessage({ span, traceData });
105
- this.#client?.capture(this.withGroups(eventMessage));
106
- }
107
- /**
108
- * Forward feedback recorded via `addFeedback()` to PostHog as a native
109
- * `$ai_feedback` event, shown as "User feedback" on the linked trace in
110
- * PostHog's AI observability UI.
111
- */
112
- async onFeedbackEvent(event) {
113
- if (!this.#client) return;
114
- const { feedback } = event;
115
- if (!feedback.traceId) {
116
- this.logger.debug("PostHog exporter: dropping feedback with no traceId; PostHog requires $ai_trace_id", {
117
- feedbackId: feedback.feedbackId
118
- });
119
- return;
120
- }
121
- const properties = {
122
- // Custom metadata goes first so it cannot overwrite the natively mapped fields below
123
- ...this.extractCustomMetadata(feedback.metadata),
124
- $ai_trace_id: feedback.traceId,
125
- // PostHog's trace UI only displays feedback events that carry $ai_feedback_text
126
- $ai_feedback_text: feedback.comment ?? String(feedback.value),
127
- feedback_id: feedback.feedbackId,
128
- feedback_type: feedback.feedbackType,
129
- feedback_value: feedback.value
130
- };
131
- const feedbackSource = feedback.feedbackSource ?? feedback.source;
132
- if (feedbackSource) properties.feedback_source = feedbackSource;
133
- if (feedback.spanId) properties.span_id = feedback.spanId;
134
- if (feedback.sourceId) properties.source_id = feedback.sourceId;
135
- if (feedback.metadata?.sessionId) properties.$ai_session_id = feedback.metadata.sessionId;
136
- try {
137
- this.#client.capture(
138
- this.withGroups({
139
- distinctId: this.getFeedbackDistinctId(feedback),
140
- event: "$ai_feedback",
141
- properties,
142
- timestamp: new Date(feedback.timestamp)
143
- })
144
- );
145
- } catch (err) {
146
- this.logger.error("PostHog exporter: failed to submit feedback", {
147
- error: err,
148
- traceId: feedback.traceId,
149
- feedbackId: feedback.feedbackId
150
- });
151
- }
152
- }
153
- getFeedbackDistinctId(feedback) {
154
- const userId = feedback.feedbackUserId ?? feedback.userId ?? feedback.metadata?.userId;
155
- if (userId) {
156
- return String(userId);
157
- }
158
- return this.config.defaultDistinctId ?? "anonymous";
159
- }
160
- /**
161
- * PostHog group analytics are keyed off the top-level `groups` field on the
162
- * capture call. The Node SDK derives the event's `$groups` from that field and
163
- * overwrites any property-level `$groups`, so group metadata carried in
164
- * properties is dropped unless it is mirrored here.
165
- */
166
- withGroups(message) {
167
- const groups = message.properties?.$groups;
168
- if (groups && typeof groups === "object" && !Array.isArray(groups)) {
169
- return { ...message, groups };
170
- }
171
- return message;
172
- }
173
- buildEventMessage(args) {
174
- const { span, traceData } = args;
175
- const endTime = span.endTime ? this.toDate(span.endTime).getTime() : Date.now();
176
- const distinctId = this.getDistinctId(span, traceData);
177
- if (span.isRootSpan) {
178
- return this.buildRootEventMessage({ span, distinctId, endTime });
179
- } else {
180
- return this.buildChildEventMessage({ span, distinctId, endTime, traceData });
181
- }
182
- }
183
- /**
184
- * Capture an explicit $ai_trace event for root spans.
185
- * This gives us control over trace-level metadata like name and tags,
186
- * rather than relying on PostHog's pseudo-trace auto-creation.
187
- */
188
- buildRootEventMessage(args) {
189
- const { span, distinctId, endTime } = args;
190
- const traceProperties = {
191
- $ai_trace_id: span.traceId,
192
- $ai_span_name: span.name,
193
- $ai_is_error: !!span.errorInfo
194
- };
195
- if (span.metadata?.sessionId) {
196
- traceProperties.$ai_session_id = span.metadata.sessionId;
197
- }
198
- if (span.input) {
199
- traceProperties.$ai_input_state = span.input;
200
- }
201
- if (span.output) {
202
- traceProperties.$ai_output_state = span.output;
203
- }
204
- if (span.errorInfo) {
205
- traceProperties.$ai_error = {
206
- message: span.errorInfo.message,
207
- ...span.errorInfo.id && { id: span.errorInfo.id },
208
- ...span.errorInfo.category && { category: span.errorInfo.category }
209
- };
210
- }
211
- if (span.tags?.length) {
212
- for (const tag of span.tags) {
213
- traceProperties[tag] = true;
214
- }
215
- }
216
- const { userId, sessionId, ...customMetadata } = span.metadata ?? {};
217
- Object.assign(traceProperties, customMetadata);
218
- return {
219
- distinctId,
220
- event: "$ai_trace",
221
- properties: traceProperties,
222
- timestamp: new Date(endTime)
223
- };
224
- }
225
- buildChildEventMessage(args) {
226
- const { span, distinctId, endTime, traceData } = args;
227
- const eventName = this.mapToPostHogEvent(span.type);
228
- const startTime = span.startTime.getTime();
229
- const latency = (endTime - startTime) / 1e3;
230
- const parentIsRootSpan = this.isParentRootSpan(span, traceData);
231
- const properties = this.buildEventProperties(span, latency, parentIsRootSpan);
232
- return {
233
- distinctId,
234
- event: eventName,
235
- properties,
236
- timestamp: new Date(endTime)
237
- };
238
- }
239
- toDate(timestamp) {
240
- return timestamp instanceof Date ? timestamp : new Date(timestamp);
241
- }
242
- mapToPostHogEvent(spanType) {
243
- if (spanType == SpanType.MODEL_GENERATION) {
244
- return "$ai_generation";
245
- }
246
- return "$ai_span";
247
- }
248
- getDistinctId(span, traceData) {
249
- if (span.metadata?.userId) {
250
- return String(span.metadata.userId);
251
- }
252
- if (traceData?.hasExtraValue(DISTINCT_ID)) {
253
- return String(traceData.getExtraValue(DISTINCT_ID));
254
- }
255
- if (this.config.defaultDistinctId) {
256
- return this.config.defaultDistinctId;
257
- }
258
- return "anonymous";
259
- }
260
- /**
261
- * Check if the parent of this span is the root span.
262
- * We need this because we don't create $ai_span for root spans,
263
- * so children of root spans should use $ai_trace_id as their $ai_parent_id.
264
- */
265
- isParentRootSpan(span, traceData) {
266
- if (!span.parentSpanId) {
267
- return false;
268
- }
269
- const parentCache = traceData.getSpan({ spanId: span.parentSpanId });
270
- if (parentCache) {
271
- return parentCache.isRootSpan;
272
- }
273
- return false;
274
- }
275
- buildEventProperties(span, latency, parentIsRootSpan = false) {
276
- const baseProperties = {
277
- $ai_trace_id: span.traceId,
278
- $ai_latency: latency,
279
- $ai_is_error: !!span.errorInfo
280
- };
281
- if (span.parentSpanId) {
282
- baseProperties.$ai_parent_id = parentIsRootSpan ? span.traceId : span.parentSpanId;
283
- }
284
- if (span.metadata?.sessionId) {
285
- baseProperties.$ai_session_id = span.metadata.sessionId;
286
- }
287
- if (span.isRootSpan && span.tags?.length) {
288
- for (const tag of span.tags) {
289
- baseProperties[tag] = true;
290
- }
291
- }
292
- if (span.type === SpanType.MODEL_GENERATION) {
293
- baseProperties.$ai_generation_id = span.id;
294
- return { ...baseProperties, ...this.buildGenerationProperties(span) };
295
- } else {
296
- baseProperties.$ai_span_id = span.id;
297
- baseProperties.$ai_span_name = span.name;
298
- return { ...baseProperties, ...this.buildSpanProperties(span) };
299
- }
300
- }
301
- extractErrorProperties(errorInfo) {
302
- if (!errorInfo) {
303
- return {};
304
- }
305
- const props = {
306
- error_message: errorInfo.message
307
- };
308
- if (errorInfo.id) {
309
- props.error_id = errorInfo.id;
310
- }
311
- if (errorInfo.category) {
312
- props.error_category = errorInfo.category;
313
- }
314
- return props;
315
- }
316
- extractCustomMetadata(metadata) {
317
- const { userId, sessionId, ...customMetadata } = metadata ?? {};
318
- return customMetadata;
319
- }
320
- buildGenerationProperties(span) {
321
- const props = {};
322
- const attrs = span.attributes ?? {};
323
- props.$ai_model = attrs.model || "unknown-model";
324
- props.$ai_provider = attrs.provider || "unknown-provider";
325
- if (span.input) props.$ai_input = this.formatMessages(span.input, "user");
326
- if (span.output) props.$ai_output_choices = this.formatMessages(span.output, "assistant");
327
- Object.assign(props, formatUsageMetrics(attrs.usage));
328
- if (attrs.parameters) {
329
- if (attrs.parameters.temperature !== void 0) props.$ai_temperature = attrs.parameters.temperature;
330
- if (attrs.parameters.maxOutputTokens !== void 0) props.$ai_max_tokens = attrs.parameters.maxOutputTokens;
331
- }
332
- if (attrs.streaming !== void 0) props.$ai_stream = attrs.streaming;
333
- return { ...props, ...this.extractErrorProperties(span.errorInfo), ...this.extractCustomMetadata(span.metadata) };
334
- }
335
- buildSpanProperties(span) {
336
- const props = {};
337
- if (span.input) props.$ai_input_state = span.input;
338
- if (span.output) props.$ai_output_state = span.output;
339
- if (span.type === SpanType.MODEL_CHUNK) {
340
- const attrs = span.attributes;
341
- if (attrs?.chunkType) props.chunk_type = attrs.chunkType;
342
- if (attrs?.sequenceNumber !== void 0) props.chunk_sequence_number = attrs.sequenceNumber;
343
- }
344
- if (span.attributes) {
345
- Object.assign(props, span.attributes);
346
- }
347
- return { ...props, ...this.extractErrorProperties(span.errorInfo), ...this.extractCustomMetadata(span.metadata) };
348
- }
349
- formatMessages(data, defaultRole = "user") {
350
- if (typeof data === "object" && data !== null && !Array.isArray(data) && "messages" in data) {
351
- const wrapped = data.messages;
352
- if (this.isMessageArray(wrapped)) {
353
- return wrapped.map((msg) => this.normalizeMessage(msg));
354
- }
355
- }
356
- if (this.isMessageArray(data)) {
357
- return data.map((msg) => this.normalizeMessage(msg));
358
- }
359
- if (typeof data === "string") {
360
- return [{ role: defaultRole, content: [{ type: "text", text: data }] }];
361
- }
362
- if (this.isSpanOutputWithToolCalls(data)) {
363
- const content = [];
364
- if (data.text) {
365
- content.push({ type: "text", text: data.text });
366
- }
367
- for (const tc of data.toolCalls) {
368
- content.push({
369
- type: "tool-call",
370
- id: tc.toolCallId,
371
- function: { name: tc.toolName, arguments: tc.args }
372
- });
373
- }
374
- return [{ role: "assistant", content }];
375
- }
376
- if (typeof data === "object" && data !== null && !Array.isArray(data) && "text" in data) {
377
- const text = data.text;
378
- if (typeof text === "string") {
379
- return [{ role: defaultRole, content: [{ type: "text", text }] }];
380
- }
381
- }
382
- return [{ role: defaultRole, content: [{ type: "text", text: this.safeStringify(data) }] }];
383
- }
384
- isSpanOutputWithToolCalls(data) {
385
- if (typeof data !== "object" || data === null || !("toolCalls" in data)) return false;
386
- const { toolCalls } = data;
387
- return Array.isArray(toolCalls) && toolCalls.length > 0;
388
- }
389
- isMessageArray(data) {
390
- if (!Array.isArray(data)) {
391
- return false;
392
- }
393
- return data.every((item) => typeof item === "object" && item !== null && "role" in item && "content" in item);
394
- }
395
- normalizeMessage(msg) {
396
- if (typeof msg.content === "string") {
397
- return {
398
- role: msg.role,
399
- content: [{ type: "text", text: msg.content }]
400
- };
401
- }
402
- return {
403
- role: msg.role,
404
- content: msg.content
405
- };
406
- }
407
- safeStringify(data) {
408
- try {
409
- return JSON.stringify(data);
410
- } catch {
411
- if (typeof data === "object" && data !== null) {
412
- return `[Non-serializable ${data.constructor?.name || "Object"}]`;
413
- }
414
- return String(data);
415
- }
416
- }
417
- /**
418
- * Force flush any buffered data to PostHog without shutting down.
419
- */
420
- async _flush() {
421
- if (this.#client) {
422
- await this.#client.flush();
423
- }
424
- }
425
- async _postShutdown() {
426
- if (this.#client) {
427
- await this.#client.shutdown();
428
- }
429
- }
24
+ const DISTINCT_ID = "distinctId";
25
+ var PosthogExporter = class PosthogExporter extends TrackingExporter {
26
+ name = "posthog";
27
+ #client;
28
+ static SERVERLESS_FLUSH_AT = 10;
29
+ static SERVERLESS_FLUSH_INTERVAL = 2e3;
30
+ static DEFAULT_FLUSH_AT = 20;
31
+ static DEFAULT_FLUSH_INTERVAL = 1e4;
32
+ constructor(config = {}) {
33
+ const apiKey = config.apiKey ?? process.env.POSTHOG_API_KEY;
34
+ super({
35
+ ...config,
36
+ apiKey
37
+ });
38
+ if (!apiKey) {
39
+ this.setDisabled("Missing required API key. Set POSTHOG_API_KEY environment variable or pass apiKey in config.");
40
+ return;
41
+ }
42
+ const clientConfig = this.buildClientConfig(this.config);
43
+ this.#client = new PostHog(apiKey, clientConfig);
44
+ const message = config.serverless ?? false ? "PostHog exporter initialized in serverless mode" : "PostHog exporter initialized";
45
+ this.logger.debug(message, config);
46
+ }
47
+ buildClientConfig(config) {
48
+ const isServerless = config.serverless ?? false;
49
+ const flushAt = config.flushAt ?? (isServerless ? PosthogExporter.SERVERLESS_FLUSH_AT : PosthogExporter.DEFAULT_FLUSH_AT);
50
+ const flushInterval = config.flushInterval ?? (isServerless ? PosthogExporter.SERVERLESS_FLUSH_INTERVAL : PosthogExporter.DEFAULT_FLUSH_INTERVAL);
51
+ const host = config.host || process.env.POSTHOG_HOST || "https://us.i.posthog.com";
52
+ if (!config.host && !process.env.POSTHOG_HOST) this.logger.info("No PostHog host specified, using US default (https://us.i.posthog.com). For EU region, set `host: \"https://eu.i.posthog.com\"` in config or POSTHOG_HOST env var. For self-hosted, provide your instance URL.");
53
+ return {
54
+ host,
55
+ flushAt,
56
+ flushInterval,
57
+ privacyMode: config.enablePrivacyMode
58
+ };
59
+ }
60
+ skipBuildRootTask = true;
61
+ async _buildRoot(_args) {
62
+ throw new Error("Method not implemented.");
63
+ }
64
+ skipCachingEventSpans = true;
65
+ async _buildEvent(args) {
66
+ const { span, traceData } = args;
67
+ const eventName = this.mapToPostHogEvent(span.type);
68
+ const distinctId = this.getDistinctId(span, traceData);
69
+ const properties = this.buildEventProperties(span, 0);
70
+ this.#client?.capture(this.withGroups({
71
+ distinctId,
72
+ event: eventName,
73
+ properties,
74
+ timestamp: span.endTime ? new Date(span.endTime) : /* @__PURE__ */ new Date()
75
+ }));
76
+ return true;
77
+ }
78
+ async _buildSpan(args) {
79
+ const { span, traceData } = args;
80
+ if (!traceData.hasExtraValue(DISTINCT_ID)) {
81
+ const userId = span.metadata?.userId;
82
+ if (userId) traceData.setExtraValue(DISTINCT_ID, String(userId));
83
+ }
84
+ return span;
85
+ }
86
+ skipSpanUpdateEvents = true;
87
+ _updateSpan(_args) {
88
+ throw new Error("Method not implemented.");
89
+ }
90
+ async _finishSpan(args) {
91
+ const { span, traceData } = args;
92
+ const cachedSpan = traceData.getSpan({ spanId: span.id });
93
+ const mergedSpan = !span.input && cachedSpan?.input ? {
94
+ ...span,
95
+ input: cachedSpan.input
96
+ } : span;
97
+ const eventMessage = this.buildEventMessage({
98
+ span: mergedSpan,
99
+ traceData
100
+ });
101
+ this.#client?.capture(this.withGroups(eventMessage));
102
+ }
103
+ async _abortSpan(args) {
104
+ const { span, reason, traceData } = args;
105
+ span.errorInfo = reason;
106
+ const eventMessage = this.buildEventMessage({
107
+ span,
108
+ traceData
109
+ });
110
+ this.#client?.capture(this.withGroups(eventMessage));
111
+ }
112
+ /**
113
+ * Forward feedback recorded via `addFeedback()` to PostHog as a native
114
+ * `$ai_feedback` event, shown as "User feedback" on the linked trace in
115
+ * PostHog's AI observability UI.
116
+ */
117
+ async onFeedbackEvent(event) {
118
+ if (!this.#client) return;
119
+ const { feedback } = event;
120
+ if (!feedback.traceId) {
121
+ this.logger.debug("PostHog exporter: dropping feedback with no traceId; PostHog requires $ai_trace_id", { feedbackId: feedback.feedbackId });
122
+ return;
123
+ }
124
+ const properties = {
125
+ ...this.extractCustomMetadata(feedback.metadata),
126
+ $ai_trace_id: feedback.traceId,
127
+ $ai_feedback_text: feedback.comment ?? String(feedback.value),
128
+ feedback_id: feedback.feedbackId,
129
+ feedback_type: feedback.feedbackType,
130
+ feedback_value: feedback.value
131
+ };
132
+ const feedbackSource = feedback.feedbackSource ?? feedback.source;
133
+ if (feedbackSource) properties.feedback_source = feedbackSource;
134
+ if (feedback.spanId) properties.span_id = feedback.spanId;
135
+ if (feedback.sourceId) properties.source_id = feedback.sourceId;
136
+ if (feedback.metadata?.sessionId) properties.$ai_session_id = feedback.metadata.sessionId;
137
+ try {
138
+ this.#client.capture(this.withGroups({
139
+ distinctId: this.getFeedbackDistinctId(feedback),
140
+ event: "$ai_feedback",
141
+ properties,
142
+ timestamp: new Date(feedback.timestamp)
143
+ }));
144
+ } catch (err) {
145
+ this.logger.error("PostHog exporter: failed to submit feedback", {
146
+ error: err,
147
+ traceId: feedback.traceId,
148
+ feedbackId: feedback.feedbackId
149
+ });
150
+ }
151
+ }
152
+ getFeedbackDistinctId(feedback) {
153
+ const userId = feedback.feedbackUserId ?? feedback.userId ?? feedback.metadata?.userId;
154
+ if (userId) return String(userId);
155
+ return this.config.defaultDistinctId ?? "anonymous";
156
+ }
157
+ /**
158
+ * PostHog group analytics are keyed off the top-level `groups` field on the
159
+ * capture call. The Node SDK derives the event's `$groups` from that field and
160
+ * overwrites any property-level `$groups`, so group metadata carried in
161
+ * properties is dropped unless it is mirrored here.
162
+ */
163
+ withGroups(message) {
164
+ const groups = message.properties?.$groups;
165
+ if (groups && typeof groups === "object" && !Array.isArray(groups)) return {
166
+ ...message,
167
+ groups
168
+ };
169
+ return message;
170
+ }
171
+ buildEventMessage(args) {
172
+ const { span, traceData } = args;
173
+ const endTime = span.endTime ? this.toDate(span.endTime).getTime() : Date.now();
174
+ const distinctId = this.getDistinctId(span, traceData);
175
+ if (span.isRootSpan) return this.buildRootEventMessage({
176
+ span,
177
+ distinctId,
178
+ endTime
179
+ });
180
+ else return this.buildChildEventMessage({
181
+ span,
182
+ distinctId,
183
+ endTime,
184
+ traceData
185
+ });
186
+ }
187
+ /**
188
+ * Capture an explicit $ai_trace event for root spans.
189
+ * This gives us control over trace-level metadata like name and tags,
190
+ * rather than relying on PostHog's pseudo-trace auto-creation.
191
+ */
192
+ buildRootEventMessage(args) {
193
+ const { span, distinctId, endTime } = args;
194
+ const traceProperties = {
195
+ $ai_trace_id: span.traceId,
196
+ $ai_span_name: span.name,
197
+ $ai_is_error: !!span.errorInfo
198
+ };
199
+ if (span.metadata?.sessionId) traceProperties.$ai_session_id = span.metadata.sessionId;
200
+ if (span.input) traceProperties.$ai_input_state = span.input;
201
+ if (span.output) traceProperties.$ai_output_state = span.output;
202
+ if (span.errorInfo) traceProperties.$ai_error = {
203
+ message: span.errorInfo.message,
204
+ ...span.errorInfo.id && { id: span.errorInfo.id },
205
+ ...span.errorInfo.category && { category: span.errorInfo.category }
206
+ };
207
+ if (span.tags?.length) for (const tag of span.tags) traceProperties[tag] = true;
208
+ const { userId, sessionId, ...customMetadata } = span.metadata ?? {};
209
+ Object.assign(traceProperties, customMetadata);
210
+ return {
211
+ distinctId,
212
+ event: "$ai_trace",
213
+ properties: traceProperties,
214
+ timestamp: new Date(endTime)
215
+ };
216
+ }
217
+ buildChildEventMessage(args) {
218
+ const { span, distinctId, endTime, traceData } = args;
219
+ const eventName = this.mapToPostHogEvent(span.type);
220
+ const latency = (endTime - span.startTime.getTime()) / 1e3;
221
+ const parentIsRootSpan = this.isParentRootSpan(span, traceData);
222
+ return {
223
+ distinctId,
224
+ event: eventName,
225
+ properties: this.buildEventProperties(span, latency, parentIsRootSpan),
226
+ timestamp: new Date(endTime)
227
+ };
228
+ }
229
+ toDate(timestamp) {
230
+ return timestamp instanceof Date ? timestamp : new Date(timestamp);
231
+ }
232
+ mapToPostHogEvent(spanType) {
233
+ if (spanType == SpanType.MODEL_GENERATION) return "$ai_generation";
234
+ return "$ai_span";
235
+ }
236
+ getDistinctId(span, traceData) {
237
+ if (span.metadata?.userId) return String(span.metadata.userId);
238
+ if (traceData?.hasExtraValue(DISTINCT_ID)) return String(traceData.getExtraValue(DISTINCT_ID));
239
+ if (this.config.defaultDistinctId) return this.config.defaultDistinctId;
240
+ return "anonymous";
241
+ }
242
+ /**
243
+ * Check if the parent of this span is the root span.
244
+ * We need this because we don't create $ai_span for root spans,
245
+ * so children of root spans should use $ai_trace_id as their $ai_parent_id.
246
+ */
247
+ isParentRootSpan(span, traceData) {
248
+ if (!span.parentSpanId) return false;
249
+ const parentCache = traceData.getSpan({ spanId: span.parentSpanId });
250
+ if (parentCache) return parentCache.isRootSpan;
251
+ return false;
252
+ }
253
+ buildEventProperties(span, latency, parentIsRootSpan = false) {
254
+ const baseProperties = {
255
+ $ai_trace_id: span.traceId,
256
+ $ai_latency: latency,
257
+ $ai_is_error: !!span.errorInfo
258
+ };
259
+ if (span.parentSpanId) baseProperties.$ai_parent_id = parentIsRootSpan ? span.traceId : span.parentSpanId;
260
+ if (span.metadata?.sessionId) baseProperties.$ai_session_id = span.metadata.sessionId;
261
+ if (span.isRootSpan && span.tags?.length) for (const tag of span.tags) baseProperties[tag] = true;
262
+ if (span.type === SpanType.MODEL_GENERATION) {
263
+ baseProperties.$ai_generation_id = span.id;
264
+ return {
265
+ ...baseProperties,
266
+ ...this.buildGenerationProperties(span)
267
+ };
268
+ } else {
269
+ baseProperties.$ai_span_id = span.id;
270
+ baseProperties.$ai_span_name = span.name;
271
+ return {
272
+ ...baseProperties,
273
+ ...this.buildSpanProperties(span)
274
+ };
275
+ }
276
+ }
277
+ extractErrorProperties(errorInfo) {
278
+ if (!errorInfo) return {};
279
+ const props = { error_message: errorInfo.message };
280
+ if (errorInfo.id) props.error_id = errorInfo.id;
281
+ if (errorInfo.category) props.error_category = errorInfo.category;
282
+ return props;
283
+ }
284
+ extractCustomMetadata(metadata) {
285
+ const { userId, sessionId, ...customMetadata } = metadata ?? {};
286
+ return customMetadata;
287
+ }
288
+ buildGenerationProperties(span) {
289
+ const props = {};
290
+ const attrs = span.attributes ?? {};
291
+ props.$ai_model = attrs.model || "unknown-model";
292
+ props.$ai_provider = attrs.provider || "unknown-provider";
293
+ if (span.input) props.$ai_input = this.formatMessages(span.input, "user");
294
+ if (span.output) props.$ai_output_choices = this.formatMessages(span.output, "assistant");
295
+ Object.assign(props, formatUsageMetrics(attrs.usage));
296
+ if (attrs.parameters) {
297
+ if (attrs.parameters.temperature !== void 0) props.$ai_temperature = attrs.parameters.temperature;
298
+ if (attrs.parameters.maxOutputTokens !== void 0) props.$ai_max_tokens = attrs.parameters.maxOutputTokens;
299
+ }
300
+ if (attrs.streaming !== void 0) props.$ai_stream = attrs.streaming;
301
+ return {
302
+ ...props,
303
+ ...this.extractErrorProperties(span.errorInfo),
304
+ ...this.extractCustomMetadata(span.metadata)
305
+ };
306
+ }
307
+ buildSpanProperties(span) {
308
+ const props = {};
309
+ if (span.input) props.$ai_input_state = span.input;
310
+ if (span.output) props.$ai_output_state = span.output;
311
+ if (span.type === SpanType.MODEL_CHUNK) {
312
+ const attrs = span.attributes;
313
+ if (attrs?.chunkType) props.chunk_type = attrs.chunkType;
314
+ if (attrs?.sequenceNumber !== void 0) props.chunk_sequence_number = attrs.sequenceNumber;
315
+ }
316
+ if (span.attributes) Object.assign(props, span.attributes);
317
+ return {
318
+ ...props,
319
+ ...this.extractErrorProperties(span.errorInfo),
320
+ ...this.extractCustomMetadata(span.metadata)
321
+ };
322
+ }
323
+ formatMessages(data, defaultRole = "user") {
324
+ if (typeof data === "object" && data !== null && !Array.isArray(data) && "messages" in data) {
325
+ const wrapped = data.messages;
326
+ if (this.isMessageArray(wrapped)) return wrapped.map((msg) => this.normalizeMessage(msg));
327
+ }
328
+ if (this.isMessageArray(data)) return data.map((msg) => this.normalizeMessage(msg));
329
+ if (typeof data === "string") return [{
330
+ role: defaultRole,
331
+ content: [{
332
+ type: "text",
333
+ text: data
334
+ }]
335
+ }];
336
+ if (this.isSpanOutputWithToolCalls(data)) {
337
+ const content = [];
338
+ if (data.text) content.push({
339
+ type: "text",
340
+ text: data.text
341
+ });
342
+ for (const tc of data.toolCalls) content.push({
343
+ type: "tool-call",
344
+ id: tc.toolCallId,
345
+ function: {
346
+ name: tc.toolName,
347
+ arguments: tc.args
348
+ }
349
+ });
350
+ return [{
351
+ role: "assistant",
352
+ content
353
+ }];
354
+ }
355
+ if (typeof data === "object" && data !== null && !Array.isArray(data) && "text" in data) {
356
+ const text = data.text;
357
+ if (typeof text === "string") return [{
358
+ role: defaultRole,
359
+ content: [{
360
+ type: "text",
361
+ text
362
+ }]
363
+ }];
364
+ }
365
+ return [{
366
+ role: defaultRole,
367
+ content: [{
368
+ type: "text",
369
+ text: this.safeStringify(data)
370
+ }]
371
+ }];
372
+ }
373
+ isSpanOutputWithToolCalls(data) {
374
+ if (typeof data !== "object" || data === null || !("toolCalls" in data)) return false;
375
+ const { toolCalls } = data;
376
+ return Array.isArray(toolCalls) && toolCalls.length > 0;
377
+ }
378
+ isMessageArray(data) {
379
+ if (!Array.isArray(data)) return false;
380
+ return data.every((item) => typeof item === "object" && item !== null && "role" in item && "content" in item);
381
+ }
382
+ normalizeMessage(msg) {
383
+ if (typeof msg.content === "string") return {
384
+ role: msg.role,
385
+ content: [{
386
+ type: "text",
387
+ text: msg.content
388
+ }]
389
+ };
390
+ return {
391
+ role: msg.role,
392
+ content: msg.content
393
+ };
394
+ }
395
+ safeStringify(data) {
396
+ try {
397
+ return JSON.stringify(data);
398
+ } catch {
399
+ if (typeof data === "object" && data !== null) return `[Non-serializable ${data.constructor?.name || "Object"}]`;
400
+ return String(data);
401
+ }
402
+ }
403
+ /**
404
+ * Force flush any buffered data to PostHog without shutting down.
405
+ */
406
+ async _flush() {
407
+ if (this.#client) await this.#client.flush();
408
+ }
409
+ async _postShutdown() {
410
+ if (this.#client) await this.#client.shutdown();
411
+ }
430
412
  };
431
-
413
+ //#endregion
432
414
  export { PosthogExporter, formatUsageMetrics };
433
- //# sourceMappingURL=index.js.map
415
+
434
416
  //# sourceMappingURL=index.js.map