@agent-native/core 0.133.2 → 0.134.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.
Files changed (213) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +62 -0
  3. package/corpus/core/docs/content/observability.mdx +41 -1
  4. package/corpus/core/docs/content/tracking.mdx +25 -0
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/agent/run-manager.ts +7 -0
  7. package/corpus/core/src/client/agent-chat-adapter.ts +10 -0
  8. package/corpus/core/src/client/analytics.ts +105 -1
  9. package/corpus/core/src/client/use-action.ts +6 -0
  10. package/corpus/core/src/deploy/build.ts +38 -1
  11. package/corpus/core/src/observability/posthog-ai.ts +294 -0
  12. package/corpus/core/src/observability/routes.ts +29 -6
  13. package/corpus/core/src/observability/traces.ts +287 -12
  14. package/corpus/core/src/observability/types.ts +11 -0
  15. package/corpus/core/src/secrets/register-framework-secrets.ts +16 -0
  16. package/corpus/core/src/server/agent-run-context.ts +21 -0
  17. package/corpus/core/src/server/capture-error.ts +8 -0
  18. package/corpus/core/src/server/core-routes-plugin.ts +75 -4
  19. package/corpus/core/src/server/deploy-environment.ts +57 -0
  20. package/corpus/core/src/server/error-noise-filter.ts +268 -0
  21. package/corpus/core/src/server/posthog-config.ts +75 -0
  22. package/corpus/core/src/server/request-context.ts +6 -0
  23. package/corpus/core/src/server/sentry-config.ts +4 -8
  24. package/corpus/core/src/server/sentry-plugin.ts +13 -29
  25. package/corpus/core/src/server/sentry.ts +13 -156
  26. package/corpus/core/src/server/ssr-handler.ts +6 -1
  27. package/corpus/core/src/shared/mcp-embed-headers.ts +1 -1
  28. package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +36 -10
  29. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +55 -1
  30. package/corpus/core/src/tracking/error-capture.ts +44 -109
  31. package/corpus/core/src/tracking/index.ts +11 -0
  32. package/corpus/core/src/tracking/posthog-exception.ts +305 -0
  33. package/corpus/core/src/tracking/providers.ts +91 -14
  34. package/corpus/core/src/tracking/redaction.ts +105 -0
  35. package/corpus/templates/content/actions/_batch-utils.ts +33 -0
  36. package/corpus/templates/content/actions/_builder-cms-read-client.ts +173 -81
  37. package/corpus/templates/content/actions/_content-files.ts +31 -21
  38. package/corpus/templates/content/actions/_database-source-utils.ts +653 -155
  39. package/corpus/templates/content/actions/_database-utils.ts +535 -41
  40. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +291 -240
  41. package/corpus/templates/content/actions/add-database-item.ts +15 -1
  42. package/corpus/templates/content/actions/attach-content-database-source.ts +156 -58
  43. package/corpus/templates/content/actions/bind-content-database-source-field.ts +2 -2
  44. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +4 -1
  45. package/corpus/templates/content/actions/delete-database-items.ts +8 -2
  46. package/corpus/templates/content/actions/disconnect-content-database-source.ts +2 -2
  47. package/corpus/templates/content/actions/duplicate-database-item.ts +15 -1
  48. package/corpus/templates/content/actions/duplicate-database-items.ts +19 -1
  49. package/corpus/templates/content/actions/execute-builder-source-execution.ts +2 -1
  50. package/corpus/templates/content/actions/get-content-database.ts +12 -64
  51. package/corpus/templates/content/actions/move-database-item.ts +4 -1
  52. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +1 -1
  53. package/corpus/templates/content/actions/prepare-builder-source-review.ts +4 -8
  54. package/corpus/templates/content/actions/preview-content-database-source-attach.ts +92 -0
  55. package/corpus/templates/content/actions/process-builder-body-hydration.ts +2 -1
  56. package/corpus/templates/content/actions/query-content-database-items.ts +64 -0
  57. package/corpus/templates/content/actions/refresh-content-database-source.ts +7 -0
  58. package/corpus/templates/content/actions/review-content-database-source-change-set.ts +1 -1
  59. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +1 -1
  60. package/corpus/templates/content/actions/stage-builder-revision.ts +1 -1
  61. package/corpus/templates/content/actions/update-content-database-personal-view.ts +29 -3
  62. package/corpus/templates/content/actions/update-content-database-view.ts +1 -1
  63. package/corpus/templates/content/actions/validate-builder-source-execution.ts +1 -1
  64. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +3 -22
  65. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +267 -105
  66. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +82 -1
  67. package/corpus/templates/content/app/hooks/use-content-database.ts +285 -24
  68. package/corpus/templates/content/app/hooks/use-document-properties.ts +7 -6
  69. package/corpus/templates/content/app/i18n-data.ts +10 -0
  70. package/corpus/templates/content/changelog/2026-07-29-large-databases-keep-useful-rows-visible.md +6 -0
  71. package/corpus/templates/content/changelog/2026-07-30-builder-source-columns-now-appear-immediately-when-connected.md +6 -0
  72. package/corpus/templates/content/changelog/2026-07-30-large-builder-backed-tables-show-useful-rows-sooner-and-fini.md +6 -0
  73. package/corpus/templates/content/changelog/2026-08-01-large-builder-databases-now-show-rows-immediately-and-finish.md +6 -0
  74. package/corpus/templates/content/parity/matrix.md +2 -1
  75. package/corpus/templates/content/parity/matrix.ts +25 -0
  76. package/corpus/templates/content/shared/api.ts +54 -4
  77. package/corpus/templates/content/shared/database-query.ts +359 -0
  78. package/dist/agent/run-manager.d.ts.map +1 -1
  79. package/dist/agent/run-manager.js +7 -0
  80. package/dist/agent/run-manager.js.map +1 -1
  81. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  82. package/dist/client/agent-chat-adapter.js +12 -0
  83. package/dist/client/agent-chat-adapter.js.map +1 -1
  84. package/dist/client/analytics.d.ts +8 -0
  85. package/dist/client/analytics.d.ts.map +1 -1
  86. package/dist/client/analytics.js +95 -1
  87. package/dist/client/analytics.js.map +1 -1
  88. package/dist/client/use-action.d.ts.map +1 -1
  89. package/dist/client/use-action.js +6 -0
  90. package/dist/client/use-action.js.map +1 -1
  91. package/dist/collab/routes.d.ts +1 -1
  92. package/dist/collab/struct-routes.d.ts +1 -1
  93. package/dist/deploy/build.d.ts.map +1 -1
  94. package/dist/deploy/build.js +38 -1
  95. package/dist/deploy/build.js.map +1 -1
  96. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  97. package/dist/notifications/routes.d.ts +3 -3
  98. package/dist/observability/posthog-ai.d.ts +126 -0
  99. package/dist/observability/posthog-ai.d.ts.map +1 -0
  100. package/dist/observability/posthog-ai.js +190 -0
  101. package/dist/observability/posthog-ai.js.map +1 -0
  102. package/dist/observability/routes.d.ts +1 -1
  103. package/dist/observability/routes.d.ts.map +1 -1
  104. package/dist/observability/routes.js +26 -6
  105. package/dist/observability/routes.js.map +1 -1
  106. package/dist/observability/traces.d.ts +9 -0
  107. package/dist/observability/traces.d.ts.map +1 -1
  108. package/dist/observability/traces.js +219 -10
  109. package/dist/observability/traces.js.map +1 -1
  110. package/dist/observability/types.d.ts +10 -0
  111. package/dist/observability/types.d.ts.map +1 -1
  112. package/dist/observability/types.js +1 -0
  113. package/dist/observability/types.js.map +1 -1
  114. package/dist/secrets/register-framework-secrets.d.ts.map +1 -1
  115. package/dist/secrets/register-framework-secrets.js +14 -0
  116. package/dist/secrets/register-framework-secrets.js.map +1 -1
  117. package/dist/server/agent-run-context.d.ts +8 -0
  118. package/dist/server/agent-run-context.d.ts.map +1 -1
  119. package/dist/server/agent-run-context.js +18 -0
  120. package/dist/server/agent-run-context.js.map +1 -1
  121. package/dist/server/capture-error.d.ts +8 -0
  122. package/dist/server/capture-error.d.ts.map +1 -1
  123. package/dist/server/capture-error.js.map +1 -1
  124. package/dist/server/core-routes-plugin.d.ts +0 -23
  125. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  126. package/dist/server/core-routes-plugin.js +71 -7
  127. package/dist/server/core-routes-plugin.js.map +1 -1
  128. package/dist/server/deploy-environment.d.ts +17 -0
  129. package/dist/server/deploy-environment.d.ts.map +1 -0
  130. package/dist/server/deploy-environment.js +47 -0
  131. package/dist/server/deploy-environment.js.map +1 -0
  132. package/dist/server/error-noise-filter.d.ts +80 -0
  133. package/dist/server/error-noise-filter.d.ts.map +1 -0
  134. package/dist/server/error-noise-filter.js +173 -0
  135. package/dist/server/error-noise-filter.js.map +1 -0
  136. package/dist/server/posthog-config.d.ts +28 -0
  137. package/dist/server/posthog-config.d.ts.map +1 -0
  138. package/dist/server/posthog-config.js +53 -0
  139. package/dist/server/posthog-config.js.map +1 -0
  140. package/dist/server/request-context.d.ts +6 -0
  141. package/dist/server/request-context.d.ts.map +1 -1
  142. package/dist/server/request-context.js.map +1 -1
  143. package/dist/server/sentry-config.d.ts +1 -0
  144. package/dist/server/sentry-config.d.ts.map +1 -1
  145. package/dist/server/sentry-config.js +3 -1
  146. package/dist/server/sentry-config.js.map +1 -1
  147. package/dist/server/sentry-plugin.d.ts.map +1 -1
  148. package/dist/server/sentry-plugin.js +12 -27
  149. package/dist/server/sentry-plugin.js.map +1 -1
  150. package/dist/server/sentry.d.ts.map +1 -1
  151. package/dist/server/sentry.js +8 -124
  152. package/dist/server/sentry.js.map +1 -1
  153. package/dist/server/ssr-handler.d.ts.map +1 -1
  154. package/dist/server/ssr-handler.js +6 -1
  155. package/dist/server/ssr-handler.js.map +1 -1
  156. package/dist/shared/mcp-embed-headers.d.ts +1 -1
  157. package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
  158. package/dist/shared/mcp-embed-headers.js +1 -1
  159. package/dist/shared/mcp-embed-headers.js.map +1 -1
  160. package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +36 -10
  161. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +55 -1
  162. package/dist/tracking/error-capture.d.ts +7 -0
  163. package/dist/tracking/error-capture.d.ts.map +1 -1
  164. package/dist/tracking/error-capture.js +10 -73
  165. package/dist/tracking/error-capture.js.map +1 -1
  166. package/dist/tracking/index.d.ts +1 -0
  167. package/dist/tracking/index.d.ts.map +1 -1
  168. package/dist/tracking/index.js +1 -0
  169. package/dist/tracking/index.js.map +1 -1
  170. package/dist/tracking/posthog-exception.d.ts +103 -0
  171. package/dist/tracking/posthog-exception.d.ts.map +1 -0
  172. package/dist/tracking/posthog-exception.js +181 -0
  173. package/dist/tracking/posthog-exception.js.map +1 -0
  174. package/dist/tracking/providers.d.ts +14 -0
  175. package/dist/tracking/providers.d.ts.map +1 -1
  176. package/dist/tracking/providers.js +63 -11
  177. package/dist/tracking/providers.js.map +1 -1
  178. package/dist/tracking/redaction.d.ts +25 -0
  179. package/dist/tracking/redaction.d.ts.map +1 -0
  180. package/dist/tracking/redaction.js +85 -0
  181. package/dist/tracking/redaction.js.map +1 -0
  182. package/docs/content/observability.mdx +41 -1
  183. package/docs/content/tracking.mdx +25 -0
  184. package/package.json +1 -1
  185. package/src/agent/run-manager.ts +7 -0
  186. package/src/client/agent-chat-adapter.ts +10 -0
  187. package/src/client/analytics.ts +105 -1
  188. package/src/client/use-action.ts +6 -0
  189. package/src/deploy/build.ts +38 -1
  190. package/src/observability/posthog-ai.ts +294 -0
  191. package/src/observability/routes.ts +29 -6
  192. package/src/observability/traces.ts +287 -12
  193. package/src/observability/types.ts +11 -0
  194. package/src/secrets/register-framework-secrets.ts +16 -0
  195. package/src/server/agent-run-context.ts +21 -0
  196. package/src/server/capture-error.ts +8 -0
  197. package/src/server/core-routes-plugin.ts +75 -4
  198. package/src/server/deploy-environment.ts +57 -0
  199. package/src/server/error-noise-filter.ts +268 -0
  200. package/src/server/posthog-config.ts +75 -0
  201. package/src/server/request-context.ts +6 -0
  202. package/src/server/sentry-config.ts +4 -8
  203. package/src/server/sentry-plugin.ts +13 -29
  204. package/src/server/sentry.ts +13 -156
  205. package/src/server/ssr-handler.ts +6 -1
  206. package/src/shared/mcp-embed-headers.ts +1 -1
  207. package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +36 -10
  208. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +55 -1
  209. package/src/tracking/error-capture.ts +44 -109
  210. package/src/tracking/index.ts +11 -0
  211. package/src/tracking/posthog-exception.ts +305 -0
  212. package/src/tracking/providers.ts +91 -14
  213. package/src/tracking/redaction.ts +105 -0
@@ -232,6 +232,7 @@ All tables are dialect-agnostic (SQLite + Postgres) and strictly additive.
232
232
  | `packages/core/src/observability/types.ts` | Shared type definitions |
233
233
  | `packages/core/src/observability/store.ts` | SQL tables + CRUD |
234
234
  | `packages/core/src/observability/traces.ts` | Auto-instrumentation |
235
+ | `packages/core/src/observability/posthog-ai.ts` | `$ai_trace` / `$ai_span` / `survey sent` emission, content bounding, `$ai_error` |
235
236
  | `packages/core/src/observability/feedback.ts` | Feedback + Frustration Index |
236
237
  | `packages/core/src/observability/evals.ts` | Eval engine (3 layers) |
237
238
  | `packages/core/src/observability/experiments.ts` | A/B testing system |
@@ -272,16 +273,17 @@ The loop emits `agent.run` (with `agent.run_id`, `agent.thread_id`, `agent.user_
272
273
 
273
274
  ## Tracking Bridge
274
275
 
275
- Instrumented agent loops also emit one server-side tracking event per completed
276
- LLM generation:
277
-
278
- - Event name: `$ai_generation`
279
- - Provider path: `track()` from `@agent-native/core/tracking`, so configured
280
- PostHog, Agent Native Analytics, Mixpanel, Amplitude, and webhook providers
281
- receive it through the same best-effort fan-out as other tracking events.
282
- - PostHog shape: uses AI Observability properties such as `$ai_trace_id`,
283
- `$ai_session_id`, `$ai_model`, `$ai_provider`, `$ai_input_tokens`,
284
- `$ai_output_tokens`, `$ai_latency`, `$ai_total_cost_usd`, and `$ai_is_error`.
276
+ Instrumented agent loops emit server-side tracking events for every run through
277
+ `track()` from `@agent-native/core/tracking`, so configured PostHog, Agent Native
278
+ Analytics, Mixpanel, Amplitude, and webhook providers receive them through the
279
+ same best-effort fan-out as other tracking events.
280
+
281
+ - Events: `$ai_trace` per run, `$ai_span` per tool call, and `$ai_generation`
282
+ per model call. Every node carries the run id as `$ai_trace_id` and links
283
+ upward through `$ai_parent_id` so a backend can rebuild the tree.
284
+ `$ai_session_id` is the thread; the browser session is separate and ships as
285
+ `$session_id`, read from `X-Agent-Native-Session-Id` via
286
+ `RequestContext.browserSessionId`. Emission lives in `posthog-ai.ts`.
285
287
  - Agent Native Analytics shape: the same event lands in `analytics_events` with
286
288
  mirrored query-friendly properties such as `run_id`, `thread_id`,
287
289
  `cost_cents_x100`, `duration_ms`, `tool_calls`, `successful_tools`,
@@ -293,6 +295,30 @@ LLM generation:
293
295
  `parent_run_id` when available. `parent_turn_id` is separate because one
294
296
  logical turn may span multiple concrete runs.
295
297
 
298
+ Constraints that are not visible from the emit site:
299
+
300
+ - **One generation per run, not per model round-trip.** The engine layer reports
301
+ aggregate usage through `onUsage` and exposes no per-step hook, so a multi-step
302
+ run collapses into a single generation carrying the whole message list.
303
+ Per-round-trip latency and intermediate turns are unavailable without a new
304
+ seam in `ai-sdk-engine.ts` / `builder-engine.ts`. Do not describe the current
305
+ output as per-step.
306
+ - **Disabled capture omits the field rather than sending an empty one.** An
307
+ empty array is indistinguishable from a run that genuinely had no messages.
308
+ Truncated content is marked, and a run over the span cap stamps
309
+ `$ai_spans_dropped` — a truncated run must not read as a complete one.
310
+ - **The structural tool-call list ships even when content capture is off.**
311
+ Backends derive their tool tags from tool-call blocks inside the output
312
+ choices and from nothing else, so tool names (without arguments) are always
313
+ emitted. The parallel first-party `tools` array stays because the dashboards
314
+ read it; that duplication is deliberate, not cleanup.
315
+ - **Only thumbs carry `sentiment`.** All four feedback types are reported, but a
316
+ category follow-up to a thumbs-down is detail about the same vote — counting
317
+ it again inflates the metric.
318
+ - **Never invent an external id to make an integration light up.** Survey-based
319
+ feedback is emitted only when a real survey id is configured, and nothing is
320
+ sent otherwise.
321
+
296
322
  Do not build a separate LLM-observability ingestion API unless there is a clear
297
323
  reason the tracking provider registry cannot express the use case. Keep prompt,
298
324
  tool input, and model output content out of tracking by default; use the existing
@@ -79,7 +79,7 @@ Set the env var and the provider auto-registers at startup. No SDK dependencies
79
79
 
80
80
  | Provider | Env vars |
81
81
  | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
82
- | PostHog | `POSTHOG_API_KEY` (required), `POSTHOG_HOST` (optional, defaults to `https://us.i.posthog.com`) |
82
+ | PostHog | `POSTHOG_API_KEY` (required), `POSTHOG_HOST` (optional, defaults to `https://us.i.posthog.com`), `POSTHOG_ERROR_TRACKING=false` (optional opt-out) |
83
83
  | Mixpanel | `MIXPANEL_TOKEN` |
84
84
  | Amplitude | `AMPLITUDE_API_KEY` |
85
85
  | Agent Native Analytics | `AGENT_NATIVE_ANALYTICS_PUBLIC_KEY` (server), `AGENT_NATIVE_ANALYTICS_ENDPOINT` (optional, defaults to `https://analytics.agent-native.com/track`) |
@@ -89,6 +89,56 @@ Multiple providers can be active simultaneously. All receive every event.
89
89
 
90
90
  Browser-side `trackEvent()` also forwards to Agent Native Analytics when `VITE_AGENT_NATIVE_ANALYTICS_PUBLIC_KEY` is present. Use `VITE_AGENT_NATIVE_ANALYTICS_ENDPOINT` to override the default browser endpoint. The built-in Agent Native Analytics sender is quiet on localhost/local dev by default; set `AGENT_NATIVE_ANALYTICS_ALLOW_LOCALHOST=true` only for an intentional local ingestion test.
91
91
 
92
+ ## Error Capture
93
+
94
+ Exceptions fan out through `server/capture-error.ts` to every registered
95
+ backend — Sentry, PostHog, and the tracking providers — from one `captureError()`
96
+ call. Backends are additive: configuring a second one does not displace the
97
+ first, and no backend is required for the others to work.
98
+
99
+ Emit through `captureError()` / `captureException()`. Never hand-roll a
100
+ `track("$exception", …)`: each backend needs its own payload shape and the
101
+ providers build it.
102
+
103
+ - **Provider-agnostic wiring must not live in a provider plugin.** The Nitro
104
+ `error` hook is in `core-routes-plugin.ts`, not `sentry-plugin.ts`, because
105
+ that plugin returns early when no `SENTRY_DSN` is set — hooking route errors
106
+ there meant an app on any other backend silently reported none.
107
+ - **Every backend applies `server/error-noise-filter.ts`.** It holds
108
+ production-tuned drop rules (expected 4xx, access-control rejections, Lambda
109
+ freeze/thaw `socket hang up`). A backend that skips it receives a firehose;
110
+ the `socket hang up` rule alone is ~10k events/day.
111
+ - **A backend can accept a malformed payload and still show a count.** PostHog
112
+ ingested the framework's camelCase `$exception` for a long time and rendered
113
+ empty, ungroupable issues — which reads as coverage, not as breakage. When
114
+ adding or changing a backend, check what an event looks like in its UI, not
115
+ just that the request returned 200.
116
+ - **Attribute the error.** Without a user id, server exceptions land under
117
+ `anonymous` and split one person in two against their browser events. Pass
118
+ `aiTraceId` for anything inside an agent run so the issue and the LLM trace
119
+ resolve to each other.
120
+
121
+ Symbolication is per-backend and not automatic: the framework uploads no source
122
+ maps to PostHog, so minified browser stacks stay minified there. Known gap, not
123
+ a bug to re-diagnose.
124
+
125
+ ### Browser keys and the SSR shell
126
+
127
+ Public keys (`POSTHOG_PUBLIC_KEY`, the Sentry client DSN) ship inside the
128
+ CDN-cached SSR shell — publishable and identical for every visitor. Server keys
129
+ never do, and are never a fallback for a public one: `POSTHOG_API_KEY` may be a
130
+ private key and this value lands in public HTML.
131
+
132
+ Browser errors post directly to the backend rather than through
133
+ `/_agent-native/track`, because that route requires a resolved session and
134
+ relaying would drop every signed-out crash.
135
+
136
+ When adding a client config field, update **both** `server/posthog-config.ts`
137
+ and the mirrored worker emitter in `deploy/build.ts` — the worker bundles a
138
+ string copy and cannot import the module, so a one-sided edit drops the config
139
+ silently in deployed builds. `posthog-config.spec.ts` pins the two outputs
140
+ together.
141
+
92
142
  ## Default Baseline Events
93
143
 
94
144
  Template roots call `configureTracking()` once during app startup. That installs default browser pageview tracking for hosted apps:
@@ -199,6 +249,10 @@ interface TrackingEvent {
199
249
  | `packages/core/src/tracking/registry.ts` | `track()`, `identify()`, `registerTrackingProvider()`, `flushTracking()` |
200
250
  | `packages/core/src/tracking/providers.ts` | Built-in providers (PostHog, Mixpanel, Amplitude, Agent Native Analytics, Webhook) and `registerBuiltinProviders()` |
201
251
  | `packages/core/src/tracking/types.ts` | `TrackingEvent` and `TrackingProvider` interfaces |
252
+ | `packages/core/src/tracking/posthog-exception.ts` | `$exception_list` builder + stack-frame parser (isomorphic: server and browser) |
253
+ | `packages/core/src/tracking/redaction.ts` | Shared bounding/redaction helpers used by every exception emitter |
254
+ | `packages/core/src/server/error-noise-filter.ts` | Provider-agnostic drop rules, applied by both Sentry `beforeSend` and the route error hook |
255
+ | `packages/core/src/server/posthog-config.ts` | Public browser PostHog config (mirrored in `deploy/build.ts`) |
202
256
 
203
257
  ## Related Skills
204
258
 
@@ -1,5 +1,11 @@
1
1
  import { trackingIdentityProperties } from "../observability/tracking-identity.js";
2
2
  import type { CaptureErrorContext } from "../server/capture-error.js";
3
+ import {
4
+ boundedText,
5
+ exceptionParts,
6
+ safeTags,
7
+ safeValue,
8
+ } from "./redaction.js";
3
9
  import { track } from "./registry.js";
4
10
 
5
11
  export type TrackingExceptionLevel =
@@ -17,95 +23,13 @@ export interface TrackingExceptionContext extends CaptureErrorContext {
17
23
  environment?: string;
18
24
  runtime?: "node" | "cli";
19
25
  source?: "server" | "cli";
20
- }
21
-
22
- const MAX_MESSAGE_LENGTH = 1000;
23
- const MAX_STACK_LENGTH = 8000;
24
- const MAX_TAGS = 30;
25
- const MAX_EXTRA_KEYS = 30;
26
- const MAX_EXTRA_VALUE_LENGTH = 1000;
27
- const SECRET_RE = /\b(?:bearer|basic)\s+[^\s]+/gi;
28
- const SECRET_KEY_RE =
29
- /(?:authorization|cookie|set[-_]?cookie|token|secret|password|passwd|pwd|api[-_]?key|apikey|credential)/i;
30
-
31
- function redact(value: string): string {
32
- return value
33
- .replace(SECRET_RE, (match) => `${match.split(/\s+/, 1)[0]} <redacted>`)
34
- .replace(
35
- /([A-Za-z0-9_$.-]*(?:authorization|cookie|token|secret|password|passwd|pwd|api[-_]?key|apikey|credential)[A-Za-z0-9_$.-]*\s*[:=]\s*)([^\s,;}]+)/gi,
36
- "$1<redacted>",
37
- );
38
- }
39
-
40
- function boundedText(value: unknown, max: number): string {
41
- const text = typeof value === "string" ? value : String(value ?? "");
42
- const safe = redact(text);
43
- return safe.length > max ? safe.slice(0, max) : safe;
44
- }
45
-
46
- function safeValue(value: unknown, depth = 2): unknown {
47
- if (
48
- value == null ||
49
- typeof value === "boolean" ||
50
- typeof value === "number"
51
- ) {
52
- return value;
53
- }
54
- if (typeof value === "string")
55
- return boundedText(value, MAX_EXTRA_VALUE_LENGTH);
56
- if (depth <= 0) return boundedText(value, MAX_EXTRA_VALUE_LENGTH);
57
- if (Array.isArray(value)) {
58
- return value.slice(0, 20).map((item) => safeValue(item, depth - 1));
59
- }
60
- if (typeof value === "object") {
61
- const out: Record<string, unknown> = {};
62
- for (const [key, child] of Object.entries(value)) {
63
- if (Object.keys(out).length >= MAX_EXTRA_KEYS) break;
64
- const safeKey = boundedText(key, 100);
65
- out[safeKey] = SECRET_KEY_RE.test(safeKey)
66
- ? "<redacted>"
67
- : safeValue(child, depth - 1);
68
- }
69
- return out;
70
- }
71
- return boundedText(value, MAX_EXTRA_VALUE_LENGTH);
72
- }
73
-
74
- function exceptionParts(error: unknown): {
75
- type: string;
76
- message: string;
77
- stack?: string;
78
- } {
79
- if (error instanceof Error) {
80
- return {
81
- type: boundedText(error.name || "Error", 200),
82
- message: boundedText(
83
- error.message || error.name || "Error",
84
- MAX_MESSAGE_LENGTH,
85
- ),
86
- ...(error.stack
87
- ? { stack: boundedText(error.stack, MAX_STACK_LENGTH) }
88
- : {}),
89
- };
90
- }
91
- return {
92
- type: "Error",
93
- message: boundedText(error, MAX_MESSAGE_LENGTH),
94
- };
95
- }
96
-
97
- function safeTags(
98
- tags: Record<string, string | undefined> | undefined,
99
- ): Record<string, string> {
100
- const out: Record<string, string> = {};
101
- for (const [key, value] of Object.entries(tags ?? {})) {
102
- if (Object.keys(out).length >= MAX_TAGS || value == null) break;
103
- const safeKey = boundedText(key, 100);
104
- out[safeKey] = SECRET_KEY_RE.test(safeKey)
105
- ? "<redacted>"
106
- : boundedText(value, 200);
107
- }
108
- return out;
26
+ /**
27
+ * Who the exception is attributed to. Without it every server exception is
28
+ * ingested as `anonymous`, which splits one person into two in any backend
29
+ * that also receives their browser events.
30
+ */
31
+ userId?: string;
32
+ orgId?: string;
109
33
  }
110
34
 
111
35
  /** Emit a bounded, redacted Node/CLI exception through first-party tracking. */
@@ -125,26 +49,37 @@ export function captureException(
125
49
  ...context.extra,
126
50
  ...(context.contexts ? { contexts: context.contexts } : {}),
127
51
  });
128
- track("$exception", {
129
- ...trackingIdentityProperties(),
130
- exceptionType: parts.type,
131
- exceptionMessage: parts.message,
132
- ...(parts.stack ? { exceptionStack: parts.stack } : {}),
133
- handled: context.handled ?? true,
134
- level: context.level ?? "error",
135
- occurredAt: new Date().toISOString(),
136
- runtime: context.runtime ?? "node",
137
- source: context.source ?? "server",
138
- ...(context.route ? { url: boundedText(context.route, 500) } : {}),
139
- ...(context.release
140
- ? { release: boundedText(context.release, 200) }
141
- : {}),
142
- ...(context.environment
143
- ? { environment: boundedText(context.environment, 100) }
144
- : {}),
145
- ...(Object.keys(tags).length ? { exceptionTags: tags } : {}),
146
- ...(extra && typeof extra === "object" ? { exceptionExtra: extra } : {}),
147
- });
52
+ track(
53
+ "$exception",
54
+ {
55
+ ...trackingIdentityProperties(),
56
+ exceptionType: parts.type,
57
+ exceptionMessage: parts.message,
58
+ ...(parts.stack ? { exceptionStack: parts.stack } : {}),
59
+ handled: context.handled ?? true,
60
+ level: context.level ?? "error",
61
+ occurredAt: new Date().toISOString(),
62
+ runtime: context.runtime ?? "node",
63
+ source: context.source ?? "server",
64
+ ...(context.route ? { url: boundedText(context.route, 500) } : {}),
65
+ ...(context.release
66
+ ? { release: boundedText(context.release, 200) }
67
+ : {}),
68
+ ...(context.environment
69
+ ? { environment: boundedText(context.environment, 100) }
70
+ : {}),
71
+ ...(context.orgId ? { orgId: boundedText(context.orgId, 200) } : {}),
72
+ // Top-level so error tracking and LLM analytics join on it.
73
+ ...(context.aiTraceId
74
+ ? { $ai_trace_id: boundedText(context.aiTraceId, 200) }
75
+ : {}),
76
+ ...(Object.keys(tags).length ? { exceptionTags: tags } : {}),
77
+ ...(extra && typeof extra === "object"
78
+ ? { exceptionExtra: extra }
79
+ : {}),
80
+ },
81
+ context.userId ? { userId: context.userId } : undefined,
82
+ );
148
83
  } catch {
149
84
  // Error reporting must never mask the original failure.
150
85
  }
@@ -12,4 +12,15 @@ export {
12
12
  type TrackingExceptionContext,
13
13
  type TrackingExceptionLevel,
14
14
  } from "./error-capture.js";
15
+ export {
16
+ errorToPostHogExceptionProperties,
17
+ parseStackFrames,
18
+ reshapeTrackedExceptionProperties,
19
+ toPostHogExceptionProperties,
20
+ type PostHogExceptionEntry,
21
+ type PostHogExceptionInput,
22
+ type PostHogExceptionLevel,
23
+ type PostHogExceptionProperties,
24
+ type PostHogStackFrame,
25
+ } from "./posthog-exception.js";
15
26
  export type { TrackingProvider, TrackingEvent } from "./types.js";
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Build PostHog error-tracking payloads from a raw JS error or from the
3
+ * camelCase exception properties the framework's own `captureException()`
4
+ * emits.
5
+ *
6
+ * PostHog's error tracking only groups and renders an issue when the event
7
+ * carries `$exception_list` with per-frame stack data. An event named
8
+ * `$exception` without it is ingested and displayed as an empty, ungroupable
9
+ * issue — which is worse than no event, because the count looks like coverage.
10
+ *
11
+ * Frame parsing follows posthog-js (itself derived from Sentry's TraceKit
12
+ * fork), because PostHog's ingestion is written against that shape:
13
+ * - `Error: …` header lines are skipped, not parsed as frames
14
+ * - lines over 1 KB are skipped (the regexes backtrack)
15
+ * - frames are capped at 50 and reversed to oldest-call-first
16
+ * - an unresolvable function name is `?`, never empty
17
+ *
18
+ * Runs unchanged in Node and the browser: no `process`, no Node built-ins.
19
+ *
20
+ * @see https://posthog.com/docs/error-tracking/installation/manual
21
+ */
22
+
23
+ import {
24
+ MAX_MESSAGE_LENGTH,
25
+ MAX_STACK_LENGTH,
26
+ boundedText,
27
+ exceptionParts,
28
+ } from "./redaction.js";
29
+
30
+ /** Matches posthog-js's `UNKNOWN_FUNCTION`. */
31
+ const UNKNOWN_FUNCTION = "?";
32
+ const STACKTRACE_FRAME_LIMIT = 50;
33
+ const MAX_STACK_LINE_LENGTH = 1024;
34
+
35
+ const ERROR_HEADER_RE = /\S*Error: /;
36
+ const WEBPACK_ERROR_RE = /\(error: (.*)\)/;
37
+
38
+ // " at fn (file:1:2)" / " at async Foo.bar (/app/x.js:2:3)" / " at /app/x.js:1:2"
39
+ const V8_FRAME_RE =
40
+ /^\s*at (?:async )?(?:(.+?)\s+\()?(?:(.+?):(\d+):(\d+)|([^)]+))\)?\s*$/;
41
+ // "fn@https://example.com/s.js:1:2" / "@https://example.com/s.js:1:2"
42
+ const GECKO_FRAME_RE = /^\s*(.*?)@(.+?)(?::(\d+))?(?::(\d+))?\s*$/;
43
+
44
+ export type PostHogExceptionLevel =
45
+ | "fatal"
46
+ | "error"
47
+ | "warning"
48
+ | "info"
49
+ | "debug";
50
+
51
+ export interface PostHogStackFrame {
52
+ /**
53
+ * Always `"custom"`. PostHog reserves the language-specific platforms for
54
+ * frames it will try to symbolicate against uploaded source maps; we upload
55
+ * none, so claiming one would render every minified frame as a failed
56
+ * resolution rather than as the raw frame it is.
57
+ */
58
+ platform: "custom";
59
+ lang: string;
60
+ function: string;
61
+ filename?: string;
62
+ lineno?: number;
63
+ colno?: number;
64
+ in_app: boolean;
65
+ resolved: boolean;
66
+ }
67
+
68
+ export interface PostHogExceptionEntry {
69
+ type: string;
70
+ value: string;
71
+ mechanism: { handled: boolean; synthetic: boolean; type?: string };
72
+ stacktrace?: { type: "raw"; frames: PostHogStackFrame[] };
73
+ }
74
+
75
+ export interface PostHogExceptionProperties {
76
+ $exception_list: PostHogExceptionEntry[];
77
+ $exception_level: PostHogExceptionLevel;
78
+ $exception_fingerprint?: string;
79
+ [key: string]: unknown;
80
+ }
81
+
82
+ export interface PostHogExceptionInput {
83
+ /** Error class name, e.g. `TypeError`. */
84
+ type: string;
85
+ /** Error message. */
86
+ value: string;
87
+ /** Raw `error.stack` string, when available. */
88
+ stack?: string;
89
+ /** `false` for errors that crashed the request/page rather than being caught. */
90
+ handled?: boolean;
91
+ /** `true` when the framework synthesized the error from a non-Error throw. */
92
+ synthetic?: boolean;
93
+ /** Mechanism label, e.g. `onunhandledrejection`, `nitro.error`. */
94
+ mechanismType?: string;
95
+ level?: PostHogExceptionLevel;
96
+ /** Overrides PostHog's default grouping. */
97
+ fingerprint?: string;
98
+ /** Frame language tag. Defaults to `javascript`. */
99
+ lang?: string;
100
+ }
101
+
102
+ function isInAppFrame(filename: string | undefined): boolean {
103
+ if (!filename) return true;
104
+ return (
105
+ !filename.startsWith("node:") &&
106
+ !filename.includes("node_modules") &&
107
+ !filename.includes("/internal/") &&
108
+ filename !== "native"
109
+ );
110
+ }
111
+
112
+ function makeFrame(
113
+ lang: string,
114
+ fn: string,
115
+ filename: string | undefined,
116
+ lineno: number | undefined,
117
+ colno: number | undefined,
118
+ ): PostHogStackFrame {
119
+ const name = !fn || fn === "<anonymous>" ? UNKNOWN_FUNCTION : fn;
120
+ return {
121
+ platform: "custom",
122
+ lang,
123
+ function: name,
124
+ ...(filename ? { filename } : {}),
125
+ ...(lineno !== undefined && Number.isFinite(lineno) ? { lineno } : {}),
126
+ ...(colno !== undefined && Number.isFinite(colno) ? { colno } : {}),
127
+ in_app: isInAppFrame(filename),
128
+ // Never `true`: we ship no source maps to PostHog, so a minified browser
129
+ // frame is exactly as informative as it looks. Claiming it is resolved
130
+ // would present a mangled name as the real one.
131
+ resolved: false,
132
+ };
133
+ }
134
+
135
+ function parseFrameLine(
136
+ line: string,
137
+ lang: string,
138
+ ): PostHogStackFrame | undefined {
139
+ const v8 = V8_FRAME_RE.exec(line);
140
+ if (v8) {
141
+ const [, fn, file, lineNo, colNo, bare] = v8;
142
+ const filename = (file ?? bare)?.replace(/^file:\/\//, "");
143
+ return makeFrame(
144
+ lang,
145
+ fn ?? UNKNOWN_FUNCTION,
146
+ filename,
147
+ lineNo ? Number(lineNo) : undefined,
148
+ colNo ? Number(colNo) : undefined,
149
+ );
150
+ }
151
+
152
+ const gecko = GECKO_FRAME_RE.exec(line);
153
+ if (gecko) {
154
+ const [, fn, file, lineNo, colNo] = gecko;
155
+ return makeFrame(
156
+ lang,
157
+ fn || UNKNOWN_FUNCTION,
158
+ file,
159
+ lineNo ? Number(lineNo) : undefined,
160
+ colNo ? Number(colNo) : undefined,
161
+ );
162
+ }
163
+
164
+ return undefined;
165
+ }
166
+
167
+ /**
168
+ * Parse a `error.stack` string into PostHog stack frames, oldest call first.
169
+ *
170
+ * Returns an empty array when nothing parsed. Callers must treat that as
171
+ * "no frames" and omit `stacktrace` entirely rather than sending an empty
172
+ * frame list — PostHog renders the latter as a stack that exists and is empty.
173
+ */
174
+ export function parseStackFrames(
175
+ stack: string | undefined,
176
+ lang = "javascript",
177
+ ): PostHogStackFrame[] {
178
+ if (!stack) return [];
179
+ const frames: PostHogStackFrame[] = [];
180
+
181
+ for (const rawLine of stack.split("\n")) {
182
+ if (rawLine.length > MAX_STACK_LINE_LENGTH) continue;
183
+ const line = WEBPACK_ERROR_RE.test(rawLine)
184
+ ? rawLine.replace(WEBPACK_ERROR_RE, "$1")
185
+ : rawLine;
186
+ if (ERROR_HEADER_RE.test(line)) continue;
187
+
188
+ const frame = parseFrameLine(line, lang);
189
+ if (frame) frames.push(frame);
190
+ if (frames.length >= STACKTRACE_FRAME_LIMIT) break;
191
+ }
192
+
193
+ frames.reverse();
194
+ return frames;
195
+ }
196
+
197
+ /**
198
+ * Build the `$exception_*` properties for a PostHog error-tracking event.
199
+ *
200
+ * The caller merges these into the event properties alongside `distinct_id`
201
+ * and any app dimensions.
202
+ */
203
+ export function toPostHogExceptionProperties(
204
+ input: PostHogExceptionInput,
205
+ ): PostHogExceptionProperties {
206
+ const lang = input.lang ?? "javascript";
207
+ const frames = parseStackFrames(input.stack, lang);
208
+ const entry: PostHogExceptionEntry = {
209
+ type: boundedText(input.type || "Error", 200),
210
+ value: boundedText(
211
+ input.value || input.type || "Error",
212
+ MAX_MESSAGE_LENGTH,
213
+ ),
214
+ mechanism: {
215
+ handled: input.handled ?? true,
216
+ synthetic: input.synthetic ?? false,
217
+ ...(input.mechanismType
218
+ ? { type: boundedText(input.mechanismType, 100) }
219
+ : {}),
220
+ },
221
+ ...(frames.length ? { stacktrace: { type: "raw" as const, frames } } : {}),
222
+ };
223
+
224
+ return {
225
+ $exception_list: [entry],
226
+ $exception_level: input.level ?? "error",
227
+ ...(input.fingerprint
228
+ ? { $exception_fingerprint: boundedText(input.fingerprint, 200) }
229
+ : {}),
230
+ };
231
+ }
232
+
233
+ /** Build `$exception_*` properties directly from a thrown value. */
234
+ export function errorToPostHogExceptionProperties(
235
+ error: unknown,
236
+ options: Omit<PostHogExceptionInput, "type" | "value" | "stack"> = {},
237
+ ): PostHogExceptionProperties {
238
+ const parts = exceptionParts(error);
239
+ return toPostHogExceptionProperties({
240
+ ...options,
241
+ type: parts.type,
242
+ value: parts.message,
243
+ stack: parts.stack,
244
+ synthetic: options.synthetic ?? !(error instanceof Error),
245
+ });
246
+ }
247
+
248
+ /**
249
+ * Reshape the camelCase properties emitted by `tracking/error-capture.ts` into
250
+ * PostHog's `$exception_list` form.
251
+ *
252
+ * Returns `undefined` when the event carries no recognizable exception fields,
253
+ * so the caller can pass it through untouched instead of inventing an empty
254
+ * issue out of an unrelated event that happens to be named `$exception`.
255
+ */
256
+ export function reshapeTrackedExceptionProperties(
257
+ properties: Record<string, unknown> | undefined,
258
+ ): Record<string, unknown> | undefined {
259
+ if (!properties) return undefined;
260
+ // Already in PostHog form (e.g. relayed from the browser) — leave it alone.
261
+ if (Array.isArray(properties.$exception_list)) return properties;
262
+
263
+ const type = properties.exceptionType;
264
+ const message = properties.exceptionMessage;
265
+ if (typeof type !== "string" && typeof message !== "string") {
266
+ return undefined;
267
+ }
268
+
269
+ const stack =
270
+ typeof properties.exceptionStack === "string"
271
+ ? properties.exceptionStack.slice(0, MAX_STACK_LENGTH)
272
+ : undefined;
273
+ const level = properties.level;
274
+
275
+ const {
276
+ exceptionType: _type,
277
+ exceptionMessage: _message,
278
+ exceptionStack: _stack,
279
+ handled: _handled,
280
+ level: _level,
281
+ ...rest
282
+ } = properties;
283
+
284
+ return {
285
+ ...rest,
286
+ ...toPostHogExceptionProperties({
287
+ type: typeof type === "string" ? type : "Error",
288
+ value: typeof message === "string" ? message : "Error",
289
+ stack,
290
+ handled:
291
+ typeof properties.handled === "boolean" ? properties.handled : true,
292
+ level: isExceptionLevel(level) ? level : "error",
293
+ }),
294
+ };
295
+ }
296
+
297
+ function isExceptionLevel(value: unknown): value is PostHogExceptionLevel {
298
+ return (
299
+ value === "fatal" ||
300
+ value === "error" ||
301
+ value === "warning" ||
302
+ value === "info" ||
303
+ value === "debug"
304
+ );
305
+ }