@agent-native/core 0.90.10 → 0.91.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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +30 -0
- package/corpus/core/docs/content/toolkit-settings.mdx +56 -3
- package/corpus/core/package.json +2 -1
- package/corpus/core/src/agent/durable-background.ts +5 -5
- package/corpus/core/src/agent/engine/ai-sdk-engine.ts +40 -8
- package/corpus/core/src/agent/engine/anthropic-engine.ts +25 -7
- package/corpus/core/src/agent/engine/output-tokens.ts +94 -0
- package/corpus/core/src/agent/production-agent.ts +34 -6
- package/corpus/core/src/agent/run-ownership.ts +29 -1
- package/corpus/core/src/agent/types.ts +3 -2
- package/corpus/core/src/changelog/parse.ts +78 -0
- package/corpus/core/src/chat-threads/schema.ts +43 -0
- package/corpus/core/src/chat-threads/store.ts +144 -12
- package/corpus/core/src/cli/workspace-dev.ts +25 -5
- package/corpus/core/src/client/AgentPanel.tsx +79 -14
- package/corpus/core/src/client/AssistantChat.tsx +359 -425
- package/corpus/core/src/client/analytics-session.ts +70 -0
- package/corpus/core/src/client/analytics.ts +175 -49
- package/corpus/core/src/client/chat/message-components.tsx +22 -2
- package/corpus/core/src/client/components/ui/message-scroller.tsx +131 -0
- package/corpus/core/src/client/conversation/AgentConversation.tsx +63 -41
- package/corpus/core/src/client/error-capture.ts +616 -0
- package/corpus/core/src/client/index.ts +12 -0
- package/corpus/core/src/client/session-replay.ts +57 -7
- package/corpus/core/src/client/settings/SettingsPanel.tsx +123 -1
- package/corpus/core/src/client/settings/SettingsTabsPage.tsx +292 -45
- package/corpus/core/src/client/settings/index.ts +1 -0
- package/corpus/core/src/client/sharing/ShareButton.tsx +14 -11
- package/corpus/core/src/client/sharing/ShareDialog.tsx +13 -10
- package/corpus/core/src/db/client.ts +12 -9
- package/corpus/core/src/mcp/builtin-tools.ts +29 -2
- package/corpus/core/src/server/agent-chat-plugin.ts +174 -79
- package/corpus/core/src/server/onboarding-html.ts +28 -16
- package/corpus/core/src/shared/reasoning-effort.ts +23 -0
- package/corpus/core/src/sharing/actions/list-resource-shares.ts +45 -1
- package/corpus/core/src/sharing/actions/share-resource.ts +9 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -2
- package/corpus/core/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +9 -6
- package/corpus/core/src/vite/client.ts +217 -35
- package/corpus/templates/analytics/.agents/skills/adhoc-analysis/SKILL.md +8 -7
- package/corpus/templates/analytics/.agents/skills/analysis-workspace/SKILL.md +14 -10
- package/corpus/templates/analytics/.agents/skills/bigquery/SKILL.md +15 -15
- package/corpus/templates/analytics/.agents/skills/cross-source-analysis/SKILL.md +1 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +38 -22
- package/corpus/templates/analytics/.agents/skills/data-programs/SKILL.md +114 -61
- package/corpus/templates/analytics/.agents/skills/data-querying/SKILL.md +1 -0
- package/corpus/templates/analytics/.agents/skills/gong/SKILL.md +7 -7
- package/corpus/templates/analytics/.agents/skills/hubspot/SKILL.md +1 -0
- package/corpus/templates/analytics/.agents/skills/prometheus/SKILL.md +1 -1
- package/corpus/templates/analytics/.builder/skills/bigquery/SKILL.md +17 -17
- package/corpus/templates/analytics/.builder/skills/gcloud/SKILL.md +5 -5
- package/corpus/templates/analytics/.builder/skills/github/SKILL.md +7 -7
- package/corpus/templates/analytics/.builder/skills/gong/SKILL.md +12 -12
- package/corpus/templates/analytics/.builder/skills/grafana/SKILL.md +7 -7
- package/corpus/templates/analytics/.builder/skills/sentry/SKILL.md +7 -7
- package/corpus/templates/analytics/.builder/skills/slack/SKILL.md +6 -6
- package/corpus/templates/analytics/.builder/skills/stripe/SKILL.md +7 -7
- package/corpus/templates/analytics/AGENTS.md +47 -2
- package/corpus/templates/analytics/actions/add-status-page-monitor.ts +36 -0
- package/corpus/templates/analytics/actions/capture-test-error.ts +33 -0
- package/corpus/templates/analytics/actions/delete-monitor.ts +24 -0
- package/corpus/templates/analytics/actions/delete-status-page.ts +23 -0
- package/corpus/templates/analytics/actions/get-error-issue.ts +36 -0
- package/corpus/templates/analytics/actions/get-monitor-stats.ts +55 -0
- package/corpus/templates/analytics/actions/get-monitor.ts +27 -0
- package/corpus/templates/analytics/actions/get-public-status-page.ts +41 -0
- package/corpus/templates/analytics/actions/get-status-page.ts +29 -0
- package/corpus/templates/analytics/actions/list-error-issues.ts +40 -0
- package/corpus/templates/analytics/actions/list-monitors.ts +21 -0
- package/corpus/templates/analytics/actions/list-status-pages.ts +21 -0
- package/corpus/templates/analytics/actions/match-error-issues.ts +51 -0
- package/corpus/templates/analytics/actions/navigate.ts +58 -4
- package/corpus/templates/analytics/actions/remove-status-page-monitor.ts +22 -0
- package/corpus/templates/analytics/actions/reorder-status-page-monitors.ts +25 -0
- package/corpus/templates/analytics/actions/resolve-error-issue.ts +38 -0
- package/corpus/templates/analytics/actions/run-monitor-check.ts +23 -0
- package/corpus/templates/analytics/actions/save-analysis.ts +1 -0
- package/corpus/templates/analytics/actions/save-monitor.ts +145 -0
- package/corpus/templates/analytics/actions/save-status-page.ts +47 -0
- package/corpus/templates/analytics/actions/view-screen.ts +212 -0
- package/corpus/templates/analytics/app/components/layout/Header.tsx +1 -0
- package/corpus/templates/analytics/app/components/layout/Layout.tsx +10 -1
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +134 -39
- package/corpus/templates/analytics/app/components/monitoring/PublicStatusView.tsx +351 -0
- package/corpus/templates/analytics/app/components/monitoring/ResponseTimeChart.tsx +174 -0
- package/corpus/templates/analytics/app/components/monitoring/UptimeStatCards.tsx +85 -0
- package/corpus/templates/analytics/app/components/monitoring/UptimeTimelineBars.tsx +106 -0
- package/corpus/templates/analytics/app/components/monitoring/chart-utils.ts +101 -0
- package/corpus/templates/analytics/app/components/monitoring/index.ts +28 -0
- package/corpus/templates/analytics/app/components/monitoring/types.ts +42 -0
- package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +34 -0
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +6 -0
- package/corpus/templates/analytics/app/i18n-data.ts +33 -0
- package/corpus/templates/analytics/app/pages/DataSources.tsx +28 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +77 -9
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +3 -3
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +7 -0
- package/corpus/templates/analytics/app/pages/monitoring/ErrorsPanel.tsx +205 -0
- package/corpus/templates/analytics/app/pages/monitoring/MonitoringPage.tsx +100 -0
- package/corpus/templates/analytics/app/pages/monitoring/UptimePanel.tsx +475 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/IssueDetail.tsx +372 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/IssueList.tsx +360 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/Sparkline.tsx +55 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/i18n.ts +106 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/types.ts +75 -0
- package/corpus/templates/analytics/app/pages/monitoring/errors/utils.ts +132 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorDetail.tsx +535 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorFormPage.tsx +1026 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorList.tsx +311 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/i18n.ts +235 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/StatusPageEditor.tsx +661 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/StatusPagesView.tsx +355 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/i18n.ts +106 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/slug.ts +24 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-pages/types.ts +20 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/status-summary.ts +87 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/types.ts +161 -0
- package/corpus/templates/analytics/app/pages/monitoring/uptime/utils.ts +210 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +76 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +74 -9
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +197 -20
- package/corpus/templates/analytics/app/root.tsx +33 -1
- package/corpus/templates/analytics/app/routes/monitoring._index.tsx +10 -0
- package/corpus/templates/analytics/app/routes/status.$slug.tsx +118 -0
- package/corpus/templates/analytics/changelog/2026-07-08-add-uptime-monitors-that-ping-your-urls-and-alert-you-when-t.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-analytics-chats-keep-large-data-dictionaries-compact-so-prov.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-create-public-status-pages-that-share-the-live-health-of-cho.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-dashboard-extension-panels-use-clearer-open-copy.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-error-capture.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-fixed-single-panel-dashboard-drags-so-they-no-longer-rewrite.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-jump-straight-from-an-error-in-a-session-recording-to-its-fu.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-monitor-detail-and-list-now-show-colorful-uptime-timelines-r.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-pinned-ask-chats-now-show-a-pin-icon-in-the-sidebar.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-publish-a-public-uptime-status-page-with-colorful-uptime-tim.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-sessions-now-hide-visitor-emails-in-demo-mode-and-show-clean.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-08-settings-are-cleaner-searchable-and-alerts-have-their-own-tab.md +5 -0
- package/corpus/templates/analytics/changelog/2026-07-08-sidebar-sections-stay-collapsed-until-opened.md +5 -0
- package/corpus/templates/analytics/changelog/2026-07-08-the-uptime-monitor-list-now-has-a-current-status-overview-wi.md +6 -0
- package/corpus/templates/analytics/docs/error-capture.md +172 -0
- package/corpus/templates/analytics/docs/uptime-monitoring.md +150 -0
- package/corpus/templates/analytics/server/db/schema-errors.ts +150 -0
- package/corpus/templates/analytics/server/db/schema-monitoring.ts +177 -0
- package/corpus/templates/analytics/server/db/schema.ts +6 -0
- package/corpus/templates/analytics/server/jobs/uptime-monitors.ts +123 -0
- package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +41 -0
- package/corpus/templates/analytics/server/lib/data-dictionary-context.ts +55 -12
- package/corpus/templates/analytics/server/lib/error-capture.ts +1331 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +47 -1
- package/corpus/templates/analytics/server/lib/monitor-stats.ts +494 -0
- package/corpus/templates/analytics/server/lib/status-pages.ts +798 -0
- package/corpus/templates/analytics/server/lib/uptime-monitors.ts +1665 -0
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +7 -37
- package/corpus/templates/analytics/server/plugins/auth.ts +11 -1
- package/corpus/templates/analytics/server/plugins/db.ts +408 -0
- package/corpus/templates/analytics/server/plugins/uptime-monitor-jobs.ts +63 -0
- package/corpus/templates/assets/app/routes/_index.tsx +1 -1
- package/corpus/templates/assets/app/routes/settings.tsx +22 -1
- package/corpus/templates/assets/changelog/2026-07-08-recent-drafts-keep-comfortable-side-padding.md +6 -0
- package/corpus/templates/assets/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/brain/app/routes/settings.tsx +43 -5
- package/corpus/templates/brain/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/calendar/app/pages/Settings.tsx +44 -6
- package/corpus/templates/calendar/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/chat/app/routes/settings.tsx +16 -1
- package/corpus/templates/chat/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/clips/actions/lib/audio-only-transcription.ts +79 -37
- package/corpus/templates/clips/actions/regenerate-chapters.ts +7 -0
- package/corpus/templates/clips/app/components/player/player-controls.tsx +35 -0
- package/corpus/templates/clips/app/components/player/video-player.tsx +162 -18
- package/corpus/templates/clips/app/hooks/use-auto-title.ts +15 -5
- package/corpus/templates/clips/app/routes/_app.settings._index.tsx +63 -6
- package/corpus/templates/clips/app/routes/download.tsx +12 -4
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -0
- package/corpus/templates/clips/app/routes/record.tsx +18 -1
- package/corpus/templates/clips/changelog/2026-07-07-auto-generated-chapter-requests-now-open-the-matching-agent-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-07-shared-clips-start-playback-reliably-from-the-first-play-click.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-07-the-clips-desktop-download-page-now-uses-the-agent-native-ap.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-chrome-extension-camera-and-microphone-menus-now-show-which-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-chrome-extension-copies-new-clip-links-after-saving.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-chrome-extension-recordings-now-continue-after-granting-came.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-mobile-shared-clips-now-have-loom-style-tap-and-skip-controls.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-recordings-and-uploads-copy-links-after-saving.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-08-settings-are-cleaner-searchable-and-collapse-s3-with-builder.md +5 -0
- package/corpus/templates/clips/changelog/2026-07-08-transcripts-are-more-reliable-for-longer-desktop-recordings-.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -0
- package/corpus/templates/clips/chrome-extension/src/background.ts +162 -13
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +14 -0
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +36 -0
- package/corpus/templates/clips/chrome-extension/src/overlay.ts +99 -25
- package/corpus/templates/clips/chrome-extension/src/permission.ts +63 -14
- package/corpus/templates/clips/chrome-extension/src/popup.ts +123 -23
- package/corpus/templates/content/actions/_builder-cms-read-client.ts +47 -0
- package/corpus/templates/content/actions/_database-source-utils.ts +33 -4
- package/corpus/templates/content/actions/add-content-database-source-field-property.ts +214 -29
- package/corpus/templates/content/actions/attach-content-database-source.ts +3 -0
- package/corpus/templates/content/actions/change-content-database-source-role.ts +1 -0
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +42 -32
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +352 -81
- package/corpus/templates/content/app/components/editor/database/settings.tsx +49 -24
- package/corpus/templates/content/app/routes/_app.settings.tsx +19 -1
- package/corpus/templates/content/changelog/2026-07-07-builder-source-tag-fields-now-import-as-multi-select.md +6 -0
- package/corpus/templates/content/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/content/shared/api.ts +3 -0
- package/corpus/templates/content/shared/properties.ts +42 -0
- package/corpus/templates/design/actions/generate-design.ts +3 -0
- package/corpus/templates/design/actions/get-design-system.ts +147 -1
- package/corpus/templates/design/app/routes/settings.tsx +16 -1
- package/corpus/templates/design/changelog/2026-07-08-design-generation-now-follows-hydrated-builder-design-system.md +6 -0
- package/corpus/templates/design/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/dispatch/app/routes/settings.tsx +23 -2
- package/corpus/templates/dispatch/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/forms/app/routes/_app.settings.tsx +16 -1
- package/corpus/templates/forms/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/macros/app/routes/settings.tsx +16 -1
- package/corpus/templates/macros/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/mail/app/components/settings/GmailFiltersSection.tsx +1 -1
- package/corpus/templates/mail/app/components/settings/SnippetsSection.tsx +1 -1
- package/corpus/templates/mail/app/pages/SettingsPage.tsx +123 -105
- package/corpus/templates/mail/changelog/2026-07-08-settings-redesigned-with-a-consistent-searchable-layout.md +5 -0
- package/corpus/templates/plan/app/routes/settings.tsx +23 -2
- package/corpus/templates/plan/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/corpus/templates/slides/actions/add-slide.ts +1 -0
- package/corpus/templates/slides/actions/get-design-system.ts +147 -1
- package/corpus/templates/slides/app/pages/Index.tsx +46 -1
- package/corpus/templates/slides/app/routes/settings.tsx +16 -1
- package/corpus/templates/slides/changelog/2026-07-08-deck-generation-now-follows-hydrated-builder-design-system-g.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-08-settings-are-cleaner-and-searchable.md +5 -0
- package/dist/agent/durable-background.d.ts +1 -1
- package/dist/agent/durable-background.d.ts.map +1 -1
- package/dist/agent/durable-background.js +5 -3
- package/dist/agent/durable-background.js.map +1 -1
- package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
- package/dist/agent/engine/ai-sdk-engine.js +24 -4
- package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
- package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
- package/dist/agent/engine/anthropic-engine.js +13 -3
- package/dist/agent/engine/anthropic-engine.js.map +1 -1
- package/dist/agent/engine/output-tokens.d.ts +27 -0
- package/dist/agent/engine/output-tokens.d.ts.map +1 -1
- package/dist/agent/engine/output-tokens.js +70 -0
- package/dist/agent/engine/output-tokens.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +28 -7
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-ownership.d.ts +6 -0
- package/dist/agent/run-ownership.d.ts.map +1 -1
- package/dist/agent/run-ownership.js +11 -1
- package/dist/agent/run-ownership.js.map +1 -1
- package/dist/agent/types.d.ts +3 -2
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/changelog/parse.d.ts +7 -0
- package/dist/changelog/parse.d.ts.map +1 -1
- package/dist/changelog/parse.js +64 -0
- package/dist/changelog/parse.js.map +1 -1
- package/dist/chat-threads/schema.d.ts +445 -0
- package/dist/chat-threads/schema.d.ts.map +1 -0
- package/dist/chat-threads/schema.js +39 -0
- package/dist/chat-threads/schema.js.map +1 -0
- package/dist/chat-threads/store.d.ts +12 -0
- package/dist/chat-threads/store.d.ts.map +1 -1
- package/dist/chat-threads/store.js +99 -10
- package/dist/chat-threads/store.js.map +1 -1
- package/dist/cli/workspace-dev.d.ts.map +1 -1
- package/dist/cli/workspace-dev.js +20 -5
- package/dist/cli/workspace-dev.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +35 -7
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +105 -183
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/analytics-session.d.ts +3 -0
- package/dist/client/analytics-session.d.ts.map +1 -0
- package/dist/client/analytics-session.js +66 -0
- package/dist/client/analytics-session.js.map +1 -0
- package/dist/client/analytics.d.ts +32 -0
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +116 -46
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/chat/message-components.d.ts +2 -1
- package/dist/client/chat/message-components.d.ts.map +1 -1
- package/dist/client/chat/message-components.js +16 -6
- package/dist/client/chat/message-components.js.map +1 -1
- package/dist/client/components/ui/message-scroller.d.ts +16 -0
- package/dist/client/components/ui/message-scroller.d.ts.map +1 -0
- package/dist/client/components/ui/message-scroller.js +25 -0
- package/dist/client/components/ui/message-scroller.js.map +1 -0
- package/dist/client/conversation/AgentConversation.d.ts +1 -1
- package/dist/client/conversation/AgentConversation.d.ts.map +1 -1
- package/dist/client/conversation/AgentConversation.js +3 -8
- package/dist/client/conversation/AgentConversation.js.map +1 -1
- package/dist/client/error-capture.d.ts +99 -0
- package/dist/client/error-capture.d.ts.map +1 -0
- package/dist/client/error-capture.js +437 -0
- package/dist/client/error-capture.js.map +1 -0
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +3 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/session-replay.d.ts +22 -0
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +45 -4
- package/dist/client/session-replay.js.map +1 -1
- package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
- package/dist/client/settings/SettingsPanel.js +100 -5
- package/dist/client/settings/SettingsPanel.js.map +1 -1
- package/dist/client/settings/SettingsTabsPage.d.ts +49 -1
- package/dist/client/settings/SettingsTabsPage.d.ts.map +1 -1
- package/dist/client/settings/SettingsTabsPage.js +131 -19
- package/dist/client/settings/SettingsTabsPage.js.map +1 -1
- package/dist/client/settings/index.d.ts +1 -1
- package/dist/client/settings/index.d.ts.map +1 -1
- package/dist/client/settings/index.js.map +1 -1
- package/dist/client/sharing/ShareButton.d.ts.map +1 -1
- package/dist/client/sharing/ShareButton.js +13 -8
- package/dist/client/sharing/ShareButton.js.map +1 -1
- package/dist/client/sharing/ShareDialog.d.ts.map +1 -1
- package/dist/client/sharing/ShareDialog.js +12 -7
- package/dist/client/sharing/ShareDialog.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +12 -7
- package/dist/db/client.js.map +1 -1
- package/dist/mcp/builtin-tools.d.ts.map +1 -1
- package/dist/mcp/builtin-tools.js +21 -2
- package/dist/mcp/builtin-tools.js.map +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/org/schema.d.ts +15 -15
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +109 -73
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/onboarding-html.d.ts.map +1 -1
- package/dist/server/onboarding-html.js +28 -16
- package/dist/server/onboarding-html.js.map +1 -1
- package/dist/shared/reasoning-effort.d.ts +1 -0
- package/dist/shared/reasoning-effort.d.ts.map +1 -1
- package/dist/shared/reasoning-effort.js +18 -0
- package/dist/shared/reasoning-effort.js.map +1 -1
- package/dist/sharing/actions/list-resource-shares.js +30 -1
- package/dist/sharing/actions/list-resource-shares.js.map +1 -1
- package/dist/sharing/actions/share-resource.js +6 -0
- package/dist/sharing/actions/share-resource.js.map +1 -1
- package/dist/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -2
- package/dist/templates/workspace-core/.agents/skills/changelog/SKILL.md +9 -6
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +192 -30
- package/dist/vite/client.js.map +1 -1
- package/docs/content/toolkit-settings.mdx +56 -3
- package/package.json +2 -1
- package/src/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -2
- package/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +9 -6
|
@@ -0,0 +1,1331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side error capture — OWNED BY THE ERROR CAPTURE FEATURE.
|
|
3
|
+
*
|
|
4
|
+
* This module is the single source of truth for Sentry-style grouping. The
|
|
5
|
+
* browser SDK sends a compact, bounded exception payload (type/message/raw
|
|
6
|
+
* stack/context) through the first-party analytics `$exception` event; the
|
|
7
|
+
* server parses the stack, computes a stable fingerprint, upserts the grouped
|
|
8
|
+
* `error_issues` row, appends an `error_events` occurrence, links it to the
|
|
9
|
+
* session replay it happened in, and prunes occurrences to a bounded retention.
|
|
10
|
+
*
|
|
11
|
+
* Pure helpers (`parseStack`, `fingerprint`, `titleFromException`,
|
|
12
|
+
* `culpritFromFrames`) have no I/O and are unit-tested. Everything is owner
|
|
13
|
+
* scoped: writes derive the tenant from the resolved analytics public key, and
|
|
14
|
+
* reads go through `accessFilter` so an org-scoped key surfaces its issues to
|
|
15
|
+
* the whole org exactly like session recordings.
|
|
16
|
+
*/
|
|
17
|
+
import { notifyWithDelivery } from "@agent-native/core/notifications";
|
|
18
|
+
import { recordChange } from "@agent-native/core/server";
|
|
19
|
+
import { accessFilter } from "@agent-native/core/sharing";
|
|
20
|
+
import {
|
|
21
|
+
and,
|
|
22
|
+
desc,
|
|
23
|
+
eq,
|
|
24
|
+
inArray,
|
|
25
|
+
isNull,
|
|
26
|
+
notInArray,
|
|
27
|
+
or,
|
|
28
|
+
sql,
|
|
29
|
+
} from "drizzle-orm";
|
|
30
|
+
|
|
31
|
+
import { getDb, schema } from "../db/index.js";
|
|
32
|
+
|
|
33
|
+
export type ExceptionLevel = "fatal" | "error" | "warning" | "info" | "debug";
|
|
34
|
+
export type IssueStatus = "unresolved" | "resolved" | "ignored";
|
|
35
|
+
|
|
36
|
+
const LEVELS: ExceptionLevel[] = ["fatal", "error", "warning", "info", "debug"];
|
|
37
|
+
const LEVEL_RANK: Record<ExceptionLevel, number> = {
|
|
38
|
+
fatal: 5,
|
|
39
|
+
error: 4,
|
|
40
|
+
warning: 3,
|
|
41
|
+
info: 2,
|
|
42
|
+
debug: 1,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Dedicated first-party analytics event name for captured exceptions. */
|
|
46
|
+
export const EXCEPTION_EVENT_NAME = "$exception";
|
|
47
|
+
|
|
48
|
+
const MAX_FRAMES = 50;
|
|
49
|
+
const MAX_RAW_STACK = 8_000;
|
|
50
|
+
const MAX_MESSAGE = 2_000;
|
|
51
|
+
const MAX_TITLE = 300;
|
|
52
|
+
const MAX_BREADCRUMBS = 30;
|
|
53
|
+
const MAX_TAG_KEYS = 30;
|
|
54
|
+
const MAX_EXTRA_KEYS = 50;
|
|
55
|
+
/** Per-issue occurrence retention. Older events are pruned at ingest. */
|
|
56
|
+
const MAX_EVENTS_PER_ISSUE = 100;
|
|
57
|
+
const DEFAULT_ISSUE_LIMIT = 50;
|
|
58
|
+
const MAX_ISSUE_LIMIT = 100;
|
|
59
|
+
const DEFAULT_EVENTS_PER_ISSUE_READ = 50;
|
|
60
|
+
const SPARKLINE_DAYS = 14;
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Pure helpers (unit tested)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export interface ParsedStackFrame {
|
|
67
|
+
function: string | null;
|
|
68
|
+
file: string | null;
|
|
69
|
+
lineno: number | null;
|
|
70
|
+
colno: number | null;
|
|
71
|
+
inApp: boolean;
|
|
72
|
+
raw: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const VENDOR_FILE_RE =
|
|
76
|
+
/node_modules|\/vendor\/|vendor[-.]|chunk-vendors|\.vite\/deps|webpack-internal|\/deps\/|cdn\.|unpkg\.com|jsdelivr\.net/i;
|
|
77
|
+
|
|
78
|
+
function isInAppFile(file: string | null): boolean {
|
|
79
|
+
if (!file) return false;
|
|
80
|
+
if (file === "<anonymous>" || file === "[native code]") return false;
|
|
81
|
+
return !VENDOR_FILE_RE.test(file);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseLocation(loc: string): {
|
|
85
|
+
file: string | null;
|
|
86
|
+
lineno: number | null;
|
|
87
|
+
colno: number | null;
|
|
88
|
+
} {
|
|
89
|
+
const cleaned = loc.trim().replace(/^\(/, "").replace(/\)$/, "");
|
|
90
|
+
const withColCol = cleaned.match(/^(.*?):(\d+):(\d+)$/);
|
|
91
|
+
if (withColCol) {
|
|
92
|
+
return {
|
|
93
|
+
file: withColCol[1] || null,
|
|
94
|
+
lineno: Number(withColCol[2]),
|
|
95
|
+
colno: Number(withColCol[3]),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const withLine = cleaned.match(/^(.*?):(\d+)$/);
|
|
99
|
+
if (withLine) {
|
|
100
|
+
return {
|
|
101
|
+
file: withLine[1] || null,
|
|
102
|
+
lineno: Number(withLine[2]),
|
|
103
|
+
colno: null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { file: cleaned || null, lineno: null, colno: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function looksLikeLocation(value: string): boolean {
|
|
110
|
+
return /:\d+(?::\d+)?\)?$/.test(value) || /^[a-z]+:\/\//i.test(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseStackLine(rawLine: string): ParsedStackFrame | null {
|
|
114
|
+
const line = rawLine.trim();
|
|
115
|
+
if (!line) return null;
|
|
116
|
+
|
|
117
|
+
// V8 / Chrome: "at fn (loc)" or "at loc"
|
|
118
|
+
if (line.startsWith("at ")) {
|
|
119
|
+
let rest = line.slice(3).trim();
|
|
120
|
+
rest = rest.replace(/^async\s+/, "");
|
|
121
|
+
const parenMatch = rest.match(/^(.*?)\s+\((.*)\)$/);
|
|
122
|
+
if (parenMatch) {
|
|
123
|
+
const fn = parenMatch[1].trim();
|
|
124
|
+
const loc = parseLocation(parenMatch[2]);
|
|
125
|
+
return {
|
|
126
|
+
function: fn || null,
|
|
127
|
+
...loc,
|
|
128
|
+
inApp: isInAppFile(loc.file),
|
|
129
|
+
raw: line,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const loc = parseLocation(rest);
|
|
133
|
+
return {
|
|
134
|
+
function: null,
|
|
135
|
+
...loc,
|
|
136
|
+
inApp: isInAppFile(loc.file),
|
|
137
|
+
raw: line,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Firefox / Safari: "fn@loc" or "@loc"
|
|
142
|
+
const atIndex = line.lastIndexOf("@");
|
|
143
|
+
if (atIndex >= 0) {
|
|
144
|
+
const fn = line.slice(0, atIndex).trim();
|
|
145
|
+
const loc = parseLocation(line.slice(atIndex + 1));
|
|
146
|
+
return {
|
|
147
|
+
function: fn || null,
|
|
148
|
+
...loc,
|
|
149
|
+
inApp: isInAppFile(loc.file),
|
|
150
|
+
raw: line,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Bare location line.
|
|
155
|
+
if (looksLikeLocation(line)) {
|
|
156
|
+
const loc = parseLocation(line);
|
|
157
|
+
return {
|
|
158
|
+
function: null,
|
|
159
|
+
...loc,
|
|
160
|
+
inApp: isInAppFile(loc.file),
|
|
161
|
+
raw: line,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Parse a raw stack string into normalized frames (bounded). */
|
|
169
|
+
export function parseStack(
|
|
170
|
+
stack: string | null | undefined,
|
|
171
|
+
): ParsedStackFrame[] {
|
|
172
|
+
if (!stack || typeof stack !== "string") return [];
|
|
173
|
+
const frames: ParsedStackFrame[] = [];
|
|
174
|
+
for (const line of stack.split("\n")) {
|
|
175
|
+
if (frames.length >= MAX_FRAMES) break;
|
|
176
|
+
const frame = parseStackLine(line);
|
|
177
|
+
if (frame) frames.push(frame);
|
|
178
|
+
}
|
|
179
|
+
return frames;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Strip content hashes + query/hash from a filename for stable grouping. */
|
|
183
|
+
export function normalizeFrameFile(file: string | null): string {
|
|
184
|
+
if (!file) return "";
|
|
185
|
+
let out = file;
|
|
186
|
+
// Drop query string + hash fragment.
|
|
187
|
+
out = out.replace(/[?#].*$/, "");
|
|
188
|
+
// Reduce URLs to pathname so host/port churn doesn't fragment groups.
|
|
189
|
+
const urlMatch = out.match(/^[a-z]+:\/\/[^/]+(\/.*)$/i);
|
|
190
|
+
if (urlMatch) out = urlMatch[1];
|
|
191
|
+
// Strip bundler content hashes in the basename: main.4f3a2b1c.js -> main.js
|
|
192
|
+
out = out.replace(/([._-])[0-9a-fA-F]{8,}(?=\.[a-z0-9]+$)/i, "");
|
|
193
|
+
out = out.replace(/([._-])[0-9a-fA-F]{8,}$/i, "");
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeMessageForFingerprint(message: string): string {
|
|
198
|
+
return message
|
|
199
|
+
.replace(/https?:\/\/\S+/gi, "<url>")
|
|
200
|
+
.replace(
|
|
201
|
+
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
|
|
202
|
+
"<uuid>",
|
|
203
|
+
)
|
|
204
|
+
.replace(/0x[0-9a-f]+/gi, "<hex>")
|
|
205
|
+
.replace(/\b\d+\b/g, "<n>")
|
|
206
|
+
.trim()
|
|
207
|
+
.slice(0, 200);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function hashHex(input: string): string {
|
|
211
|
+
let h = 0x811c9dc5;
|
|
212
|
+
for (let i = 0; i < input.length; i++) {
|
|
213
|
+
h ^= input.charCodeAt(i);
|
|
214
|
+
h = Math.imul(h, 0x01000193);
|
|
215
|
+
}
|
|
216
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function topFrame(frames: ParsedStackFrame[]): ParsedStackFrame | null {
|
|
220
|
+
return frames.find((frame) => frame.inApp) ?? frames[0] ?? null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Stable grouping key. Prefers error type + top in-app frame
|
|
225
|
+
* (function + normalized file, ignoring line/col so small edits don't split a
|
|
226
|
+
* group). Falls back to a normalized message when there is no usable stack.
|
|
227
|
+
*/
|
|
228
|
+
export function fingerprint(
|
|
229
|
+
type: string,
|
|
230
|
+
frames: ParsedStackFrame[],
|
|
231
|
+
message: string,
|
|
232
|
+
): string {
|
|
233
|
+
const frame = topFrame(frames);
|
|
234
|
+
const key =
|
|
235
|
+
frame && (frame.file || frame.function)
|
|
236
|
+
? `${type}|${normalizeFrameFile(frame.file)}|${frame.function ?? ""}`
|
|
237
|
+
: `${type}|${normalizeMessageForFingerprint(message)}`;
|
|
238
|
+
return hashHex(key);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Human-readable issue title: "Type: first line of message". */
|
|
242
|
+
export function titleFromException(type: string, message: string): string {
|
|
243
|
+
const firstLine = (message || "").split("\n")[0]?.trim() ?? "";
|
|
244
|
+
const title = firstLine ? `${type}: ${firstLine}` : type;
|
|
245
|
+
return title.slice(0, MAX_TITLE);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Best-effort culprit — the top in-app frame as "fn (file:line)". */
|
|
249
|
+
export function culpritFromFrames(frames: ParsedStackFrame[]): string | null {
|
|
250
|
+
const frame = topFrame(frames);
|
|
251
|
+
if (!frame) return null;
|
|
252
|
+
const file = normalizeFrameFile(frame.file);
|
|
253
|
+
const base = file ? file.split("/").pop() || file : null;
|
|
254
|
+
const location = base
|
|
255
|
+
? frame.lineno != null
|
|
256
|
+
? `${base}:${frame.lineno}`
|
|
257
|
+
: base
|
|
258
|
+
: null;
|
|
259
|
+
const fn = frame.function || "?";
|
|
260
|
+
return location ? `${fn} (${location})` : fn;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Split a session console/diagnostics error line ("TypeError: x is not a
|
|
265
|
+
* function") back into the `{ type, message }` the SDK sent at capture time.
|
|
266
|
+
* The replay recorder serializes errors as `${name}: ${message}`, which mirrors
|
|
267
|
+
* the SDK's `error.name` / `error.message`, so this recovers the exact
|
|
268
|
+
* fingerprint inputs from what the sessions UI already has on screen.
|
|
269
|
+
*/
|
|
270
|
+
export function deriveConsoleExceptionIdentity(raw: string): {
|
|
271
|
+
type: string;
|
|
272
|
+
message: string;
|
|
273
|
+
} {
|
|
274
|
+
const text = (raw || "").trim();
|
|
275
|
+
const idx = text.indexOf(": ");
|
|
276
|
+
if (idx > 0) {
|
|
277
|
+
const prefix = text.slice(0, idx);
|
|
278
|
+
// Error names are bare identifiers (TypeError, DOMException, FooError); a
|
|
279
|
+
// prefix with spaces is a plain message, not a type.
|
|
280
|
+
if (/^[A-Za-z_$][\w$.]{0,79}$/.test(prefix)) {
|
|
281
|
+
return { type: prefix, message: text.slice(idx + 2) };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { type: "Error", message: text };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** A session console error line to resolve back to its grouped issue. */
|
|
288
|
+
export interface ConsoleErrorSignature {
|
|
289
|
+
/** Caller-chosen id echoed back in the match result. */
|
|
290
|
+
key: string;
|
|
291
|
+
/** Console source (`window-error` / `unhandledrejection` / `console`). */
|
|
292
|
+
source?: string | null;
|
|
293
|
+
message: string;
|
|
294
|
+
stack?: string | null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Candidate fingerprints for a session console error line, computed with the
|
|
299
|
+
* exact same `parseStack` + `fingerprint` helpers as ingest — so a match is
|
|
300
|
+
* authoritative, not a parallel heuristic. Returns the primary fingerprint
|
|
301
|
+
* plus, for an unhandled rejection of a plain `Error`, the `UnhandledRejection`
|
|
302
|
+
* variant the SDK records (it renames a bare `Error` reason at capture time).
|
|
303
|
+
*/
|
|
304
|
+
export function candidateFingerprintsForConsole(
|
|
305
|
+
signature: ConsoleErrorSignature,
|
|
306
|
+
): string[] {
|
|
307
|
+
const frames = parseStack(signature.stack ?? undefined);
|
|
308
|
+
const { type, message } = deriveConsoleExceptionIdentity(signature.message);
|
|
309
|
+
const fingerprints = [fingerprint(type, frames, message)];
|
|
310
|
+
if (signature.source === "unhandledrejection" && type === "Error") {
|
|
311
|
+
fingerprints.push(fingerprint("UnhandledRejection", frames, message));
|
|
312
|
+
}
|
|
313
|
+
return Array.from(new Set(fingerprints));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function maxLevel(a: ExceptionLevel, b: ExceptionLevel): ExceptionLevel {
|
|
317
|
+
return LEVEL_RANK[a] >= LEVEL_RANK[b] ? a : b;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function coerceLevel(
|
|
321
|
+
value: unknown,
|
|
322
|
+
fallback: ExceptionLevel = "error",
|
|
323
|
+
): ExceptionLevel {
|
|
324
|
+
return typeof value === "string" && (LEVELS as string[]).includes(value)
|
|
325
|
+
? (value as ExceptionLevel)
|
|
326
|
+
: fallback;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Ingest
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
export interface IngestScope {
|
|
334
|
+
ownerEmail: string;
|
|
335
|
+
orgId: string | null;
|
|
336
|
+
/** Resolved analytics public key id, used to link the session replay. */
|
|
337
|
+
publicKeyId?: string | null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Analytics-derived dimensions carried on the forked `$exception` event. */
|
|
341
|
+
export interface DerivedExceptionFields {
|
|
342
|
+
app: string | null;
|
|
343
|
+
template: string | null;
|
|
344
|
+
url: string | null;
|
|
345
|
+
userId: string | null;
|
|
346
|
+
anonymousId: string | null;
|
|
347
|
+
userKey: string | null;
|
|
348
|
+
sessionId: string | null;
|
|
349
|
+
/** ISO occurrence time (already normalized by the analytics ingest). */
|
|
350
|
+
timestamp: string;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface RawExceptionInput {
|
|
354
|
+
type: string;
|
|
355
|
+
message: string;
|
|
356
|
+
rawStack: string | null;
|
|
357
|
+
handled: boolean;
|
|
358
|
+
level: ExceptionLevel;
|
|
359
|
+
release: string | null;
|
|
360
|
+
environment: string | null;
|
|
361
|
+
clientRecordingId: string | null;
|
|
362
|
+
tags: Record<string, string>;
|
|
363
|
+
extra: Record<string, unknown>;
|
|
364
|
+
breadcrumbs: unknown[];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function nowIso(): string {
|
|
368
|
+
return new Date().toISOString();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function randomHex(bytes: number): string {
|
|
372
|
+
const arr = new Uint8Array(bytes);
|
|
373
|
+
if (!globalThis.crypto?.getRandomValues) {
|
|
374
|
+
throw new Error("Secure random generation is unavailable");
|
|
375
|
+
}
|
|
376
|
+
globalThis.crypto.getRandomValues(arr);
|
|
377
|
+
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function newId(prefix: string): string {
|
|
381
|
+
return `${prefix}_${randomHex(12)}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function asString(value: unknown): string | null {
|
|
385
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
386
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
387
|
+
return String(value);
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function boundedRecord(
|
|
393
|
+
value: unknown,
|
|
394
|
+
maxKeys: number,
|
|
395
|
+
stringifyValues: boolean,
|
|
396
|
+
): Record<string, unknown> {
|
|
397
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
398
|
+
const out: Record<string, unknown> = {};
|
|
399
|
+
let count = 0;
|
|
400
|
+
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
401
|
+
if (count >= maxKeys) break;
|
|
402
|
+
out[key] = stringifyValues ? String(raw) : raw;
|
|
403
|
+
count += 1;
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Extract a normalized exception payload from a forked `$exception` event. */
|
|
409
|
+
export function extractExceptionInput(
|
|
410
|
+
properties: Record<string, unknown>,
|
|
411
|
+
): RawExceptionInput {
|
|
412
|
+
const type = asString(properties.exceptionType) || "Error";
|
|
413
|
+
const message = (asString(properties.exceptionMessage) || "").slice(
|
|
414
|
+
0,
|
|
415
|
+
MAX_MESSAGE,
|
|
416
|
+
);
|
|
417
|
+
const rawStack = asString(properties.exceptionStack);
|
|
418
|
+
const handled = properties.handled === true;
|
|
419
|
+
const level = coerceLevel(properties.level);
|
|
420
|
+
const clientRecordingId =
|
|
421
|
+
asString(properties.sessionReplayId) ||
|
|
422
|
+
asString((properties as any).clientRecordingId);
|
|
423
|
+
const breadcrumbs = Array.isArray(properties.breadcrumbs)
|
|
424
|
+
? (properties.breadcrumbs as unknown[]).slice(-MAX_BREADCRUMBS)
|
|
425
|
+
: [];
|
|
426
|
+
return {
|
|
427
|
+
type,
|
|
428
|
+
message,
|
|
429
|
+
rawStack: rawStack ? rawStack.slice(0, MAX_RAW_STACK) : null,
|
|
430
|
+
handled,
|
|
431
|
+
level,
|
|
432
|
+
release: asString(properties.release),
|
|
433
|
+
environment: asString(properties.environment),
|
|
434
|
+
clientRecordingId,
|
|
435
|
+
tags: boundedRecord(properties.exceptionTags, MAX_TAG_KEYS, true) as Record<
|
|
436
|
+
string,
|
|
437
|
+
string
|
|
438
|
+
>,
|
|
439
|
+
extra: boundedRecord(properties.exceptionExtra, MAX_EXTRA_KEYS, false),
|
|
440
|
+
breadcrumbs,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function resolveSessionRecordingId(
|
|
445
|
+
scope: IngestScope,
|
|
446
|
+
clientRecordingId: string | null,
|
|
447
|
+
): Promise<string | null> {
|
|
448
|
+
if (!clientRecordingId) return null;
|
|
449
|
+
const db = getDb() as any;
|
|
450
|
+
const conditions: any[] = [
|
|
451
|
+
eq(schema.sessionRecordings.clientRecordingId, clientRecordingId),
|
|
452
|
+
eq(schema.sessionRecordings.ownerEmail, scope.ownerEmail),
|
|
453
|
+
scope.orgId
|
|
454
|
+
? eq(schema.sessionRecordings.orgId, scope.orgId)
|
|
455
|
+
: isNull(schema.sessionRecordings.orgId),
|
|
456
|
+
];
|
|
457
|
+
if (scope.publicKeyId) {
|
|
458
|
+
conditions.push(
|
|
459
|
+
eq(schema.sessionRecordings.publicKeyId, scope.publicKeyId),
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
const [row] = await db
|
|
463
|
+
.select({ id: schema.sessionRecordings.id })
|
|
464
|
+
.from(schema.sessionRecordings)
|
|
465
|
+
.where(and(...conditions))
|
|
466
|
+
.limit(1);
|
|
467
|
+
return row?.id ?? null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function changeScope(scope: IngestScope): { owner?: string; orgId?: string } {
|
|
471
|
+
return scope.orgId ? { orgId: scope.orgId } : { owner: scope.ownerEmail };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function pruneAndCountUsers(
|
|
475
|
+
issueId: string,
|
|
476
|
+
scope: IngestScope,
|
|
477
|
+
): Promise<{ usersAffected: number }> {
|
|
478
|
+
const db = getDb() as any;
|
|
479
|
+
const keep = await db
|
|
480
|
+
.select({ id: schema.errorEvents.id })
|
|
481
|
+
.from(schema.errorEvents)
|
|
482
|
+
.where(
|
|
483
|
+
and(
|
|
484
|
+
eq(schema.errorEvents.issueId, issueId),
|
|
485
|
+
eq(schema.errorEvents.ownerEmail, scope.ownerEmail),
|
|
486
|
+
scope.orgId
|
|
487
|
+
? eq(schema.errorEvents.orgId, scope.orgId)
|
|
488
|
+
: isNull(schema.errorEvents.orgId),
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
.orderBy(
|
|
492
|
+
desc(schema.errorEvents.occurredAt),
|
|
493
|
+
desc(schema.errorEvents.createdAt),
|
|
494
|
+
)
|
|
495
|
+
.limit(MAX_EVENTS_PER_ISSUE);
|
|
496
|
+
const keepIds = keep.map((row: any) => row.id);
|
|
497
|
+
if (keepIds.length >= MAX_EVENTS_PER_ISSUE) {
|
|
498
|
+
await db
|
|
499
|
+
.delete(schema.errorEvents)
|
|
500
|
+
.where(
|
|
501
|
+
and(
|
|
502
|
+
eq(schema.errorEvents.issueId, issueId),
|
|
503
|
+
eq(schema.errorEvents.ownerEmail, scope.ownerEmail),
|
|
504
|
+
scope.orgId
|
|
505
|
+
? eq(schema.errorEvents.orgId, scope.orgId)
|
|
506
|
+
: isNull(schema.errorEvents.orgId),
|
|
507
|
+
notInArray(schema.errorEvents.id, keepIds),
|
|
508
|
+
),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
const [row] = await db
|
|
512
|
+
.select({
|
|
513
|
+
users: sql<number>`count(distinct coalesce(${schema.errorEvents.userKey}, ${schema.errorEvents.anonymousId}, ${schema.errorEvents.id}))`,
|
|
514
|
+
})
|
|
515
|
+
.from(schema.errorEvents)
|
|
516
|
+
.where(
|
|
517
|
+
and(
|
|
518
|
+
eq(schema.errorEvents.issueId, issueId),
|
|
519
|
+
eq(schema.errorEvents.ownerEmail, scope.ownerEmail),
|
|
520
|
+
scope.orgId
|
|
521
|
+
? eq(schema.errorEvents.orgId, scope.orgId)
|
|
522
|
+
: isNull(schema.errorEvents.orgId),
|
|
523
|
+
),
|
|
524
|
+
);
|
|
525
|
+
return { usersAffected: Number(row?.users ?? 0) };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export interface IngestExceptionResult {
|
|
529
|
+
issueId: string;
|
|
530
|
+
eventId: string;
|
|
531
|
+
isNewIssue: boolean;
|
|
532
|
+
sessionRecordingId: string | null;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function findIssueForFingerprint(
|
|
536
|
+
db: any,
|
|
537
|
+
scope: IngestScope,
|
|
538
|
+
fingerprint: string,
|
|
539
|
+
) {
|
|
540
|
+
const [issue] = await db
|
|
541
|
+
.select()
|
|
542
|
+
.from(schema.errorIssues)
|
|
543
|
+
.where(
|
|
544
|
+
and(
|
|
545
|
+
eq(schema.errorIssues.ownerEmail, scope.ownerEmail),
|
|
546
|
+
scope.orgId
|
|
547
|
+
? eq(schema.errorIssues.orgId, scope.orgId)
|
|
548
|
+
: isNull(schema.errorIssues.orgId),
|
|
549
|
+
eq(schema.errorIssues.fingerprint, fingerprint),
|
|
550
|
+
),
|
|
551
|
+
)
|
|
552
|
+
.limit(1);
|
|
553
|
+
return issue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function updateIssueForOccurrence(
|
|
557
|
+
db: any,
|
|
558
|
+
existing: any,
|
|
559
|
+
params: {
|
|
560
|
+
raw: RawExceptionInput;
|
|
561
|
+
derived: DerivedExceptionFields;
|
|
562
|
+
eventId: string;
|
|
563
|
+
sessionRecordingId: string | null;
|
|
564
|
+
occurredAt: string;
|
|
565
|
+
now: string;
|
|
566
|
+
title: string;
|
|
567
|
+
culprit: string | null;
|
|
568
|
+
},
|
|
569
|
+
): Promise<string> {
|
|
570
|
+
const firstSeenAt =
|
|
571
|
+
params.occurredAt < existing.firstSeenAt
|
|
572
|
+
? params.occurredAt
|
|
573
|
+
: existing.firstSeenAt;
|
|
574
|
+
const lastSeenAt =
|
|
575
|
+
params.occurredAt > existing.lastSeenAt
|
|
576
|
+
? params.occurredAt
|
|
577
|
+
: existing.lastSeenAt;
|
|
578
|
+
// Reopen a resolved issue on regression; leave ignored issues muted.
|
|
579
|
+
const status: IssueStatus =
|
|
580
|
+
existing.status === "resolved" ? "unresolved" : existing.status;
|
|
581
|
+
await db
|
|
582
|
+
.update(schema.errorIssues)
|
|
583
|
+
.set({
|
|
584
|
+
title: params.title,
|
|
585
|
+
culprit: params.culprit,
|
|
586
|
+
level: maxLevel(coerceLevel(existing.level), params.raw.level),
|
|
587
|
+
status,
|
|
588
|
+
firstSeenAt,
|
|
589
|
+
lastSeenAt,
|
|
590
|
+
eventCount: sql`${schema.errorIssues.eventCount} + 1`,
|
|
591
|
+
sampleEventId: params.eventId,
|
|
592
|
+
lastSessionRecordingId:
|
|
593
|
+
params.sessionRecordingId ?? existing.lastSessionRecordingId ?? null,
|
|
594
|
+
app: params.derived.app ?? existing.app ?? null,
|
|
595
|
+
template: params.derived.template ?? existing.template ?? null,
|
|
596
|
+
updatedAt: params.now,
|
|
597
|
+
})
|
|
598
|
+
.where(eq(schema.errorIssues.id, existing.id));
|
|
599
|
+
return existing.id;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Insert one occurrence and upsert its grouped issue. Owner scoped; caller
|
|
604
|
+
* supplies the tenant resolved from the analytics public key.
|
|
605
|
+
*/
|
|
606
|
+
export async function ingestException(
|
|
607
|
+
scope: IngestScope,
|
|
608
|
+
raw: RawExceptionInput,
|
|
609
|
+
derived: DerivedExceptionFields,
|
|
610
|
+
): Promise<IngestExceptionResult> {
|
|
611
|
+
const db = getDb() as any;
|
|
612
|
+
const frames = parseStack(raw.rawStack);
|
|
613
|
+
const fp = fingerprint(raw.type, frames, raw.message);
|
|
614
|
+
const title = titleFromException(raw.type, raw.message);
|
|
615
|
+
const culprit = culpritFromFrames(frames);
|
|
616
|
+
const occurredAt = derived.timestamp || nowIso();
|
|
617
|
+
const now = nowIso();
|
|
618
|
+
const sessionRecordingId = await resolveSessionRecordingId(
|
|
619
|
+
scope,
|
|
620
|
+
raw.clientRecordingId,
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
const existing = await findIssueForFingerprint(db, scope, fp);
|
|
624
|
+
|
|
625
|
+
const eventId = newId("errev");
|
|
626
|
+
let isNewIssue = !existing;
|
|
627
|
+
let issueId: string;
|
|
628
|
+
|
|
629
|
+
if (existing) {
|
|
630
|
+
issueId = await updateIssueForOccurrence(db, existing, {
|
|
631
|
+
raw,
|
|
632
|
+
derived,
|
|
633
|
+
eventId,
|
|
634
|
+
sessionRecordingId,
|
|
635
|
+
occurredAt,
|
|
636
|
+
now,
|
|
637
|
+
title,
|
|
638
|
+
culprit,
|
|
639
|
+
});
|
|
640
|
+
} else {
|
|
641
|
+
issueId = newId("erriss");
|
|
642
|
+
try {
|
|
643
|
+
await db.insert(schema.errorIssues).values({
|
|
644
|
+
id: issueId,
|
|
645
|
+
fingerprint: fp,
|
|
646
|
+
type: raw.type,
|
|
647
|
+
title,
|
|
648
|
+
culprit,
|
|
649
|
+
level: raw.level,
|
|
650
|
+
status: "unresolved",
|
|
651
|
+
firstSeenAt: occurredAt,
|
|
652
|
+
lastSeenAt: occurredAt,
|
|
653
|
+
eventCount: 1,
|
|
654
|
+
usersAffected: 0,
|
|
655
|
+
sampleEventId: eventId,
|
|
656
|
+
lastSessionRecordingId: sessionRecordingId,
|
|
657
|
+
assignee: null,
|
|
658
|
+
app: derived.app,
|
|
659
|
+
template: derived.template,
|
|
660
|
+
createdAt: now,
|
|
661
|
+
updatedAt: now,
|
|
662
|
+
ownerEmail: scope.ownerEmail,
|
|
663
|
+
orgId: scope.orgId,
|
|
664
|
+
visibility: scope.orgId ? "org" : "private",
|
|
665
|
+
});
|
|
666
|
+
} catch (err) {
|
|
667
|
+
const racedIssue = await findIssueForFingerprint(db, scope, fp);
|
|
668
|
+
if (!racedIssue) throw err;
|
|
669
|
+
isNewIssue = false;
|
|
670
|
+
issueId = await updateIssueForOccurrence(db, racedIssue, {
|
|
671
|
+
raw,
|
|
672
|
+
derived,
|
|
673
|
+
eventId,
|
|
674
|
+
sessionRecordingId,
|
|
675
|
+
occurredAt,
|
|
676
|
+
now,
|
|
677
|
+
title,
|
|
678
|
+
culprit,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
await db.insert(schema.errorEvents).values({
|
|
684
|
+
id: eventId,
|
|
685
|
+
issueId,
|
|
686
|
+
fingerprint: fp,
|
|
687
|
+
type: raw.type,
|
|
688
|
+
message: raw.message,
|
|
689
|
+
culprit,
|
|
690
|
+
level: raw.level,
|
|
691
|
+
stack: JSON.stringify(frames),
|
|
692
|
+
rawStack: raw.rawStack,
|
|
693
|
+
handled: raw.handled,
|
|
694
|
+
url: derived.url,
|
|
695
|
+
userId: derived.userId,
|
|
696
|
+
anonymousId: derived.anonymousId,
|
|
697
|
+
userKey: derived.userKey,
|
|
698
|
+
sessionId: derived.sessionId,
|
|
699
|
+
clientRecordingId: raw.clientRecordingId,
|
|
700
|
+
sessionRecordingId,
|
|
701
|
+
release: raw.release,
|
|
702
|
+
environment: raw.environment,
|
|
703
|
+
tags: JSON.stringify(raw.tags ?? {}),
|
|
704
|
+
extra: JSON.stringify(raw.extra ?? {}),
|
|
705
|
+
breadcrumbs: JSON.stringify(raw.breadcrumbs ?? []),
|
|
706
|
+
occurredAt,
|
|
707
|
+
createdAt: now,
|
|
708
|
+
ownerEmail: scope.ownerEmail,
|
|
709
|
+
orgId: scope.orgId,
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
const { usersAffected } = await pruneAndCountUsers(issueId, scope);
|
|
713
|
+
await db
|
|
714
|
+
.update(schema.errorIssues)
|
|
715
|
+
.set({ usersAffected })
|
|
716
|
+
.where(eq(schema.errorIssues.id, issueId));
|
|
717
|
+
|
|
718
|
+
recordChange({
|
|
719
|
+
source: "error-issues",
|
|
720
|
+
type: isNewIssue ? "add" : "change",
|
|
721
|
+
key: issueId,
|
|
722
|
+
...changeScope(scope),
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
if (isNewIssue) {
|
|
726
|
+
await notifyNewIssue(scope, { issueId, title, level: raw.level }).catch(
|
|
727
|
+
() => {
|
|
728
|
+
// New-issue alerts are best-effort; never fail ingest on delivery.
|
|
729
|
+
},
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return { issueId, eventId, isNewIssue, sessionRecordingId };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Fork the `$exception` events out of an analytics batch and ingest them.
|
|
738
|
+
* Best-effort: a malformed exception must never reject the analytics ingest.
|
|
739
|
+
*/
|
|
740
|
+
export async function ingestAnalyticsExceptionEvents(
|
|
741
|
+
scope: IngestScope,
|
|
742
|
+
events: Array<{
|
|
743
|
+
properties: Record<string, unknown>;
|
|
744
|
+
derived: DerivedExceptionFields;
|
|
745
|
+
}>,
|
|
746
|
+
): Promise<{ ingested: number }> {
|
|
747
|
+
let ingested = 0;
|
|
748
|
+
for (const item of events) {
|
|
749
|
+
try {
|
|
750
|
+
await ingestException(
|
|
751
|
+
scope,
|
|
752
|
+
extractExceptionInput(item.properties),
|
|
753
|
+
item.derived,
|
|
754
|
+
);
|
|
755
|
+
ingested += 1;
|
|
756
|
+
} catch (error) {
|
|
757
|
+
console.warn("[error-capture] Failed to ingest exception event:", error);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return { ingested };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function notifyNewIssue(
|
|
764
|
+
scope: IngestScope,
|
|
765
|
+
issue: { issueId: string; title: string; level: ExceptionLevel },
|
|
766
|
+
): Promise<void> {
|
|
767
|
+
await notifyWithDelivery(
|
|
768
|
+
{
|
|
769
|
+
severity: issue.level === "fatal" ? "critical" : "warning",
|
|
770
|
+
title: `New error: ${issue.title}`,
|
|
771
|
+
body: "A new JavaScript error was captured in your app.",
|
|
772
|
+
channels: ["inbox"],
|
|
773
|
+
metadata: {
|
|
774
|
+
kind: "error_issue",
|
|
775
|
+
issueId: issue.issueId,
|
|
776
|
+
level: issue.level,
|
|
777
|
+
path: `/monitoring?view=errors&issue=${issue.issueId}`,
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
// The notification inbox is owner-scoped; the issue's owner is the analytics
|
|
781
|
+
// key owner, so notify them (org members still see the issue in the UI via
|
|
782
|
+
// `accessFilter`).
|
|
783
|
+
{ owner: scope.ownerEmail },
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
// Reads + triage
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
|
|
791
|
+
export interface ErrorReadScope {
|
|
792
|
+
userEmail: string;
|
|
793
|
+
orgId: string | null;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function accessCtx(scope: ErrorReadScope) {
|
|
797
|
+
return { userEmail: scope.userEmail, orgId: scope.orgId ?? undefined };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function issuesAccessFilter(
|
|
801
|
+
scope: ErrorReadScope,
|
|
802
|
+
minRole?: "viewer" | "editor",
|
|
803
|
+
) {
|
|
804
|
+
return accessFilter(
|
|
805
|
+
schema.errorIssues,
|
|
806
|
+
schema.errorIssueShares,
|
|
807
|
+
accessCtx(scope),
|
|
808
|
+
minRole ?? "viewer",
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function textContains(column: any, value: string) {
|
|
813
|
+
const escaped = value.toLowerCase().replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
814
|
+
return sql`lower(coalesce(${column}, '')) like ${`%${escaped}%`} escape '\\'`;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** A session console error line resolved to its grouped, access-scoped issue. */
|
|
818
|
+
export interface MatchedErrorIssue {
|
|
819
|
+
issueId: string;
|
|
820
|
+
status: IssueStatus;
|
|
821
|
+
title: string;
|
|
822
|
+
fingerprint: string;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const MAX_MATCH_SIGNATURES = 100;
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Resolve a batch of session console error lines to their captured issues, in a
|
|
829
|
+
* single access-scoped query. Each signature's fingerprint is computed with the
|
|
830
|
+
* same helpers as ingest, so a hit is the very same group the error was filed
|
|
831
|
+
* under — enabling a session recording to deep-link straight to issue detail.
|
|
832
|
+
* Lines with no matching captured issue are simply omitted from the result.
|
|
833
|
+
*/
|
|
834
|
+
export async function matchErrorIssuesBySignatures(
|
|
835
|
+
scope: ErrorReadScope,
|
|
836
|
+
signatures: ConsoleErrorSignature[],
|
|
837
|
+
): Promise<Record<string, MatchedErrorIssue>> {
|
|
838
|
+
const bounded = (signatures ?? []).slice(0, MAX_MATCH_SIGNATURES);
|
|
839
|
+
const candidatesByKey = new Map<string, string[]>();
|
|
840
|
+
const allFingerprints = new Set<string>();
|
|
841
|
+
for (const signature of bounded) {
|
|
842
|
+
if (!signature?.key || !signature.message) continue;
|
|
843
|
+
const fingerprints = candidateFingerprintsForConsole(signature);
|
|
844
|
+
if (!fingerprints.length) continue;
|
|
845
|
+
candidatesByKey.set(signature.key, fingerprints);
|
|
846
|
+
for (const fp of fingerprints) allFingerprints.add(fp);
|
|
847
|
+
}
|
|
848
|
+
if (!allFingerprints.size) return {};
|
|
849
|
+
|
|
850
|
+
const db = getDb() as any;
|
|
851
|
+
const rows = await db
|
|
852
|
+
.select({
|
|
853
|
+
id: schema.errorIssues.id,
|
|
854
|
+
status: schema.errorIssues.status,
|
|
855
|
+
title: schema.errorIssues.title,
|
|
856
|
+
fingerprint: schema.errorIssues.fingerprint,
|
|
857
|
+
})
|
|
858
|
+
.from(schema.errorIssues)
|
|
859
|
+
.where(
|
|
860
|
+
and(
|
|
861
|
+
inArray(schema.errorIssues.fingerprint, Array.from(allFingerprints)),
|
|
862
|
+
issuesAccessFilter(scope),
|
|
863
|
+
),
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
const byFingerprint = new Map<string, MatchedErrorIssue>();
|
|
867
|
+
for (const row of rows) {
|
|
868
|
+
if (byFingerprint.has(row.fingerprint)) continue;
|
|
869
|
+
byFingerprint.set(row.fingerprint, {
|
|
870
|
+
issueId: row.id,
|
|
871
|
+
status: row.status as IssueStatus,
|
|
872
|
+
title: row.title,
|
|
873
|
+
fingerprint: row.fingerprint,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const result: Record<string, MatchedErrorIssue> = {};
|
|
878
|
+
for (const [key, fingerprints] of candidatesByKey) {
|
|
879
|
+
for (const fp of fingerprints) {
|
|
880
|
+
const match = byFingerprint.get(fp);
|
|
881
|
+
if (match) {
|
|
882
|
+
result[key] = match;
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return result;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export interface ListErrorIssuesFilters {
|
|
891
|
+
status?: IssueStatus | "all";
|
|
892
|
+
query?: string;
|
|
893
|
+
app?: string;
|
|
894
|
+
sort?: "lastSeen" | "eventCount" | "firstSeen";
|
|
895
|
+
limit?: number;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
export interface ErrorIssueSummary {
|
|
899
|
+
id: string;
|
|
900
|
+
fingerprint: string;
|
|
901
|
+
type: string;
|
|
902
|
+
title: string;
|
|
903
|
+
culprit: string | null;
|
|
904
|
+
level: ExceptionLevel;
|
|
905
|
+
status: IssueStatus;
|
|
906
|
+
firstSeenAt: string;
|
|
907
|
+
lastSeenAt: string;
|
|
908
|
+
eventCount: number;
|
|
909
|
+
usersAffected: number;
|
|
910
|
+
lastSessionRecordingId: string | null;
|
|
911
|
+
lastSessionRecordingPath: string | null;
|
|
912
|
+
assignee: string | null;
|
|
913
|
+
app: string | null;
|
|
914
|
+
template: string | null;
|
|
915
|
+
/** Daily occurrence counts for the last SPARKLINE_DAYS, oldest first. */
|
|
916
|
+
sparkline: number[];
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function recordingPath(recordingId: string | null): string | null {
|
|
920
|
+
return recordingId ? `/sessions/${recordingId}` : null;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function sparklinesForIssues(
|
|
924
|
+
issueIds: string[],
|
|
925
|
+
): Promise<Map<string, number[]>> {
|
|
926
|
+
const result = new Map<string, number[]>();
|
|
927
|
+
if (!issueIds.length) return result;
|
|
928
|
+
const db = getDb() as any;
|
|
929
|
+
const since = new Date(
|
|
930
|
+
Date.now() - SPARKLINE_DAYS * 24 * 60 * 60 * 1000,
|
|
931
|
+
).toISOString();
|
|
932
|
+
const rows = await db
|
|
933
|
+
.select({
|
|
934
|
+
issueId: schema.errorEvents.issueId,
|
|
935
|
+
day: sql<string>`substr(${schema.errorEvents.occurredAt}, 1, 10)`,
|
|
936
|
+
count: sql<number>`count(*)`,
|
|
937
|
+
})
|
|
938
|
+
.from(schema.errorEvents)
|
|
939
|
+
.where(
|
|
940
|
+
and(
|
|
941
|
+
inArray(schema.errorEvents.issueId, issueIds),
|
|
942
|
+
sql`${schema.errorEvents.occurredAt} >= ${since}`,
|
|
943
|
+
),
|
|
944
|
+
)
|
|
945
|
+
.groupBy(
|
|
946
|
+
schema.errorEvents.issueId,
|
|
947
|
+
sql`substr(${schema.errorEvents.occurredAt}, 1, 10)`,
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
const days: string[] = [];
|
|
951
|
+
for (let i = SPARKLINE_DAYS - 1; i >= 0; i--) {
|
|
952
|
+
days.push(
|
|
953
|
+
new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
const dayIndex = new Map(days.map((day, index) => [day, index]));
|
|
957
|
+
for (const id of issueIds) result.set(id, new Array(SPARKLINE_DAYS).fill(0));
|
|
958
|
+
for (const row of rows) {
|
|
959
|
+
const series = result.get(row.issueId);
|
|
960
|
+
const index = dayIndex.get(String(row.day));
|
|
961
|
+
if (series && index !== undefined) series[index] = Number(row.count ?? 0);
|
|
962
|
+
}
|
|
963
|
+
return result;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
export async function listErrorIssues(
|
|
967
|
+
scope: ErrorReadScope,
|
|
968
|
+
filters: ListErrorIssuesFilters = {},
|
|
969
|
+
): Promise<ErrorIssueSummary[]> {
|
|
970
|
+
const db = getDb() as any;
|
|
971
|
+
const limit = Math.min(
|
|
972
|
+
MAX_ISSUE_LIMIT,
|
|
973
|
+
Math.max(1, filters.limit ?? DEFAULT_ISSUE_LIMIT),
|
|
974
|
+
);
|
|
975
|
+
const conditions: any[] = [issuesAccessFilter(scope)];
|
|
976
|
+
if (filters.status && filters.status !== "all") {
|
|
977
|
+
conditions.push(eq(schema.errorIssues.status, filters.status));
|
|
978
|
+
}
|
|
979
|
+
if (filters.app) conditions.push(eq(schema.errorIssues.app, filters.app));
|
|
980
|
+
const query = filters.query?.trim();
|
|
981
|
+
if (query) {
|
|
982
|
+
conditions.push(
|
|
983
|
+
or(
|
|
984
|
+
textContains(schema.errorIssues.title, query),
|
|
985
|
+
textContains(schema.errorIssues.type, query),
|
|
986
|
+
textContains(schema.errorIssues.culprit, query),
|
|
987
|
+
textContains(schema.errorIssues.fingerprint, query),
|
|
988
|
+
),
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
const orderColumn =
|
|
992
|
+
filters.sort === "eventCount"
|
|
993
|
+
? schema.errorIssues.eventCount
|
|
994
|
+
: filters.sort === "firstSeen"
|
|
995
|
+
? schema.errorIssues.firstSeenAt
|
|
996
|
+
: schema.errorIssues.lastSeenAt;
|
|
997
|
+
|
|
998
|
+
const rows = await db
|
|
999
|
+
.select()
|
|
1000
|
+
.from(schema.errorIssues)
|
|
1001
|
+
.where(and(...conditions))
|
|
1002
|
+
.orderBy(desc(orderColumn))
|
|
1003
|
+
.limit(limit);
|
|
1004
|
+
|
|
1005
|
+
const sparklines = await sparklinesForIssues(rows.map((row: any) => row.id));
|
|
1006
|
+
return rows.map((row: any) => ({
|
|
1007
|
+
id: row.id,
|
|
1008
|
+
fingerprint: row.fingerprint,
|
|
1009
|
+
type: row.type,
|
|
1010
|
+
title: row.title,
|
|
1011
|
+
culprit: row.culprit ?? null,
|
|
1012
|
+
level: coerceLevel(row.level),
|
|
1013
|
+
status: row.status as IssueStatus,
|
|
1014
|
+
firstSeenAt: row.firstSeenAt,
|
|
1015
|
+
lastSeenAt: row.lastSeenAt,
|
|
1016
|
+
eventCount: Number(row.eventCount ?? 0),
|
|
1017
|
+
usersAffected: Number(row.usersAffected ?? 0),
|
|
1018
|
+
lastSessionRecordingId: row.lastSessionRecordingId ?? null,
|
|
1019
|
+
lastSessionRecordingPath: recordingPath(row.lastSessionRecordingId ?? null),
|
|
1020
|
+
assignee: row.assignee ?? null,
|
|
1021
|
+
app: row.app ?? null,
|
|
1022
|
+
template: row.template ?? null,
|
|
1023
|
+
sparkline: sparklines.get(row.id) ?? new Array(SPARKLINE_DAYS).fill(0),
|
|
1024
|
+
}));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
export interface ErrorEventDetail {
|
|
1028
|
+
id: string;
|
|
1029
|
+
type: string;
|
|
1030
|
+
message: string;
|
|
1031
|
+
culprit: string | null;
|
|
1032
|
+
level: ExceptionLevel;
|
|
1033
|
+
stack: ParsedStackFrame[];
|
|
1034
|
+
handled: boolean;
|
|
1035
|
+
url: string | null;
|
|
1036
|
+
userId: string | null;
|
|
1037
|
+
anonymousId: string | null;
|
|
1038
|
+
userKey: string | null;
|
|
1039
|
+
sessionId: string | null;
|
|
1040
|
+
sessionRecordingId: string | null;
|
|
1041
|
+
sessionRecordingPath: string | null;
|
|
1042
|
+
release: string | null;
|
|
1043
|
+
environment: string | null;
|
|
1044
|
+
tags: Record<string, unknown>;
|
|
1045
|
+
extra: Record<string, unknown>;
|
|
1046
|
+
breadcrumbs: unknown[];
|
|
1047
|
+
occurredAt: string;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
export interface ErrorIssueDetail {
|
|
1051
|
+
issue: ErrorIssueSummary;
|
|
1052
|
+
events: ErrorEventDetail[];
|
|
1053
|
+
sessions: Array<{ recordingId: string; path: string }>;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function parseJson<T>(value: unknown, fallback: T): T {
|
|
1057
|
+
if (typeof value !== "string" || !value) return fallback;
|
|
1058
|
+
try {
|
|
1059
|
+
return JSON.parse(value) as T;
|
|
1060
|
+
} catch {
|
|
1061
|
+
return fallback;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Resolve accessible session recordings for a batch of occurrences. Only
|
|
1067
|
+
* returns recordings the caller can actually view (via `accessFilter`), so a
|
|
1068
|
+
* "watch replay" link never leaks a recording outside its share scope.
|
|
1069
|
+
*/
|
|
1070
|
+
async function resolveAccessibleRecordings(
|
|
1071
|
+
scope: ErrorReadScope,
|
|
1072
|
+
srIds: string[],
|
|
1073
|
+
clientIds: string[],
|
|
1074
|
+
): Promise<{ byId: Set<string>; byClientId: Map<string, string> }> {
|
|
1075
|
+
const byId = new Set<string>();
|
|
1076
|
+
const byClientId = new Map<string, string>();
|
|
1077
|
+
if (!srIds.length && !clientIds.length) return { byId, byClientId };
|
|
1078
|
+
const db = getDb() as any;
|
|
1079
|
+
const idClauses: any[] = [];
|
|
1080
|
+
if (srIds.length) idClauses.push(inArray(schema.sessionRecordings.id, srIds));
|
|
1081
|
+
if (clientIds.length) {
|
|
1082
|
+
idClauses.push(
|
|
1083
|
+
inArray(schema.sessionRecordings.clientRecordingId, clientIds),
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
const rows = await db
|
|
1087
|
+
.select({
|
|
1088
|
+
id: schema.sessionRecordings.id,
|
|
1089
|
+
clientRecordingId: schema.sessionRecordings.clientRecordingId,
|
|
1090
|
+
})
|
|
1091
|
+
.from(schema.sessionRecordings)
|
|
1092
|
+
.where(
|
|
1093
|
+
and(
|
|
1094
|
+
accessFilter(
|
|
1095
|
+
schema.sessionRecordings,
|
|
1096
|
+
schema.sessionRecordingShares,
|
|
1097
|
+
accessCtx(scope),
|
|
1098
|
+
),
|
|
1099
|
+
or(...idClauses),
|
|
1100
|
+
),
|
|
1101
|
+
)
|
|
1102
|
+
.limit(200);
|
|
1103
|
+
for (const row of rows) {
|
|
1104
|
+
byId.add(row.id);
|
|
1105
|
+
if (row.clientRecordingId) byClientId.set(row.clientRecordingId, row.id);
|
|
1106
|
+
}
|
|
1107
|
+
return { byId, byClientId };
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export async function getErrorIssue(
|
|
1111
|
+
scope: ErrorReadScope,
|
|
1112
|
+
issueId: string,
|
|
1113
|
+
options: { eventsLimit?: number } = {},
|
|
1114
|
+
): Promise<ErrorIssueDetail> {
|
|
1115
|
+
const db = getDb() as any;
|
|
1116
|
+
const [issueRow] = await db
|
|
1117
|
+
.select()
|
|
1118
|
+
.from(schema.errorIssues)
|
|
1119
|
+
.where(and(eq(schema.errorIssues.id, issueId), issuesAccessFilter(scope)))
|
|
1120
|
+
.limit(1);
|
|
1121
|
+
if (!issueRow) {
|
|
1122
|
+
throw Object.assign(new Error("Error issue not found"), {
|
|
1123
|
+
statusCode: 404,
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const eventsLimit = Math.min(
|
|
1128
|
+
200,
|
|
1129
|
+
Math.max(1, options.eventsLimit ?? DEFAULT_EVENTS_PER_ISSUE_READ),
|
|
1130
|
+
);
|
|
1131
|
+
const eventRows = await db
|
|
1132
|
+
.select()
|
|
1133
|
+
.from(schema.errorEvents)
|
|
1134
|
+
.where(
|
|
1135
|
+
and(
|
|
1136
|
+
eq(schema.errorEvents.issueId, issueId),
|
|
1137
|
+
eq(schema.errorEvents.ownerEmail, issueRow.ownerEmail),
|
|
1138
|
+
issueRow.orgId
|
|
1139
|
+
? eq(schema.errorEvents.orgId, issueRow.orgId)
|
|
1140
|
+
: isNull(schema.errorEvents.orgId),
|
|
1141
|
+
),
|
|
1142
|
+
)
|
|
1143
|
+
.orderBy(desc(schema.errorEvents.occurredAt))
|
|
1144
|
+
.limit(eventsLimit);
|
|
1145
|
+
|
|
1146
|
+
const srIds = Array.from(
|
|
1147
|
+
new Set(
|
|
1148
|
+
eventRows
|
|
1149
|
+
.map((row: any) => row.sessionRecordingId)
|
|
1150
|
+
.filter((value: unknown): value is string => Boolean(value)),
|
|
1151
|
+
),
|
|
1152
|
+
) as string[];
|
|
1153
|
+
const clientIds = Array.from(
|
|
1154
|
+
new Set(
|
|
1155
|
+
eventRows
|
|
1156
|
+
.filter((row: any) => !row.sessionRecordingId && row.clientRecordingId)
|
|
1157
|
+
.map((row: any) => row.clientRecordingId as string),
|
|
1158
|
+
),
|
|
1159
|
+
) as string[];
|
|
1160
|
+
const { byId, byClientId } = await resolveAccessibleRecordings(
|
|
1161
|
+
scope,
|
|
1162
|
+
srIds,
|
|
1163
|
+
clientIds,
|
|
1164
|
+
);
|
|
1165
|
+
|
|
1166
|
+
const sessions = new Map<string, { recordingId: string; path: string }>();
|
|
1167
|
+
const events: ErrorEventDetail[] = eventRows.map((row: any) => {
|
|
1168
|
+
let recordingId: string | null = null;
|
|
1169
|
+
if (row.sessionRecordingId && byId.has(row.sessionRecordingId)) {
|
|
1170
|
+
recordingId = row.sessionRecordingId;
|
|
1171
|
+
} else if (row.clientRecordingId && byClientId.has(row.clientRecordingId)) {
|
|
1172
|
+
recordingId = byClientId.get(row.clientRecordingId) ?? null;
|
|
1173
|
+
}
|
|
1174
|
+
if (recordingId && !sessions.has(recordingId)) {
|
|
1175
|
+
sessions.set(recordingId, {
|
|
1176
|
+
recordingId,
|
|
1177
|
+
path: `/sessions/${recordingId}`,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
return {
|
|
1181
|
+
id: row.id,
|
|
1182
|
+
type: row.type,
|
|
1183
|
+
message: row.message ?? "",
|
|
1184
|
+
culprit: row.culprit ?? null,
|
|
1185
|
+
level: coerceLevel(row.level),
|
|
1186
|
+
stack: parseJson<ParsedStackFrame[]>(row.stack, []),
|
|
1187
|
+
handled: Boolean(row.handled),
|
|
1188
|
+
url: row.url ?? null,
|
|
1189
|
+
userId: row.userId ?? null,
|
|
1190
|
+
anonymousId: row.anonymousId ?? null,
|
|
1191
|
+
userKey: row.userKey ?? null,
|
|
1192
|
+
sessionId: row.sessionId ?? null,
|
|
1193
|
+
sessionRecordingId: recordingId,
|
|
1194
|
+
sessionRecordingPath: recordingPath(recordingId),
|
|
1195
|
+
release: row.release ?? null,
|
|
1196
|
+
environment: row.environment ?? null,
|
|
1197
|
+
tags: parseJson<Record<string, unknown>>(row.tags, {}),
|
|
1198
|
+
extra: parseJson<Record<string, unknown>>(row.extra, {}),
|
|
1199
|
+
breadcrumbs: parseJson<unknown[]>(row.breadcrumbs, []),
|
|
1200
|
+
occurredAt: row.occurredAt,
|
|
1201
|
+
};
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
const sparklines = await sparklinesForIssues([issueId]);
|
|
1205
|
+
const issue: ErrorIssueSummary = {
|
|
1206
|
+
id: issueRow.id,
|
|
1207
|
+
fingerprint: issueRow.fingerprint,
|
|
1208
|
+
type: issueRow.type,
|
|
1209
|
+
title: issueRow.title,
|
|
1210
|
+
culprit: issueRow.culprit ?? null,
|
|
1211
|
+
level: coerceLevel(issueRow.level),
|
|
1212
|
+
status: issueRow.status as IssueStatus,
|
|
1213
|
+
firstSeenAt: issueRow.firstSeenAt,
|
|
1214
|
+
lastSeenAt: issueRow.lastSeenAt,
|
|
1215
|
+
eventCount: Number(issueRow.eventCount ?? 0),
|
|
1216
|
+
usersAffected: Number(issueRow.usersAffected ?? 0),
|
|
1217
|
+
lastSessionRecordingId: issueRow.lastSessionRecordingId ?? null,
|
|
1218
|
+
lastSessionRecordingPath: recordingPath(
|
|
1219
|
+
issueRow.lastSessionRecordingId ?? null,
|
|
1220
|
+
),
|
|
1221
|
+
assignee: issueRow.assignee ?? null,
|
|
1222
|
+
app: issueRow.app ?? null,
|
|
1223
|
+
template: issueRow.template ?? null,
|
|
1224
|
+
sparkline: sparklines.get(issueId) ?? new Array(SPARKLINE_DAYS).fill(0),
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
return { issue, events, sessions: Array.from(sessions.values()) };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
export interface UpdateErrorIssueInput {
|
|
1231
|
+
status?: IssueStatus;
|
|
1232
|
+
assignee?: string | null;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
export async function updateErrorIssue(
|
|
1236
|
+
scope: ErrorReadScope,
|
|
1237
|
+
issueId: string,
|
|
1238
|
+
patch: UpdateErrorIssueInput,
|
|
1239
|
+
): Promise<{ id: string; status: IssueStatus; assignee: string | null }> {
|
|
1240
|
+
const db = getDb() as any;
|
|
1241
|
+
const set: Record<string, unknown> = { updatedAt: nowIso() };
|
|
1242
|
+
if (patch.status) set.status = patch.status;
|
|
1243
|
+
if (patch.assignee !== undefined) set.assignee = patch.assignee;
|
|
1244
|
+
|
|
1245
|
+
const updated = await db
|
|
1246
|
+
.update(schema.errorIssues)
|
|
1247
|
+
.set(set)
|
|
1248
|
+
.where(
|
|
1249
|
+
and(
|
|
1250
|
+
eq(schema.errorIssues.id, issueId),
|
|
1251
|
+
issuesAccessFilter(scope, "editor"),
|
|
1252
|
+
),
|
|
1253
|
+
)
|
|
1254
|
+
.returning({
|
|
1255
|
+
id: schema.errorIssues.id,
|
|
1256
|
+
status: schema.errorIssues.status,
|
|
1257
|
+
assignee: schema.errorIssues.assignee,
|
|
1258
|
+
ownerEmail: schema.errorIssues.ownerEmail,
|
|
1259
|
+
orgId: schema.errorIssues.orgId,
|
|
1260
|
+
visibility: schema.errorIssues.visibility,
|
|
1261
|
+
});
|
|
1262
|
+
if (!updated.length) {
|
|
1263
|
+
throw Object.assign(new Error("Error issue not found or not editable"), {
|
|
1264
|
+
statusCode: 404,
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
const row = updated[0];
|
|
1268
|
+
recordChange({
|
|
1269
|
+
source: "error-issues",
|
|
1270
|
+
type: "change",
|
|
1271
|
+
key: issueId,
|
|
1272
|
+
...(row.visibility === "org" && row.orgId
|
|
1273
|
+
? { orgId: row.orgId }
|
|
1274
|
+
: { owner: row.ownerEmail }),
|
|
1275
|
+
});
|
|
1276
|
+
return {
|
|
1277
|
+
id: row.id,
|
|
1278
|
+
status: row.status as IssueStatus,
|
|
1279
|
+
assignee: row.assignee ?? null,
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// ---------------------------------------------------------------------------
|
|
1284
|
+
// Test helper (drives the pipeline end-to-end without the browser SDK)
|
|
1285
|
+
// ---------------------------------------------------------------------------
|
|
1286
|
+
|
|
1287
|
+
export async function captureTestError(
|
|
1288
|
+
scope: ErrorReadScope,
|
|
1289
|
+
input: { message?: string; type?: string } = {},
|
|
1290
|
+
): Promise<IngestExceptionResult> {
|
|
1291
|
+
const type = input.type || "Error";
|
|
1292
|
+
const message =
|
|
1293
|
+
input.message || "Test error from the analytics Error capture pipeline";
|
|
1294
|
+
const rawStack = [
|
|
1295
|
+
`${type}: ${message}`,
|
|
1296
|
+
" at triggerTestError (app/pages/monitoring/errors/test.ts:12:9)",
|
|
1297
|
+
" at onClick (app/pages/monitoring/ErrorsPanel.tsx:42:5)",
|
|
1298
|
+
].join("\n");
|
|
1299
|
+
return ingestException(
|
|
1300
|
+
{ ownerEmail: scope.userEmail, orgId: scope.orgId },
|
|
1301
|
+
{
|
|
1302
|
+
type,
|
|
1303
|
+
message,
|
|
1304
|
+
rawStack,
|
|
1305
|
+
handled: true,
|
|
1306
|
+
level: "error",
|
|
1307
|
+
release: null,
|
|
1308
|
+
environment: "test",
|
|
1309
|
+
clientRecordingId: null,
|
|
1310
|
+
tags: { source: "capture-test-error" },
|
|
1311
|
+
extra: {},
|
|
1312
|
+
breadcrumbs: [
|
|
1313
|
+
{
|
|
1314
|
+
timestamp: nowIso(),
|
|
1315
|
+
category: "test",
|
|
1316
|
+
message: "Generated a sample error to verify the pipeline",
|
|
1317
|
+
},
|
|
1318
|
+
],
|
|
1319
|
+
},
|
|
1320
|
+
{
|
|
1321
|
+
app: null,
|
|
1322
|
+
template: null,
|
|
1323
|
+
url: null,
|
|
1324
|
+
userId: scope.userEmail,
|
|
1325
|
+
anonymousId: null,
|
|
1326
|
+
userKey: scope.userEmail,
|
|
1327
|
+
sessionId: null,
|
|
1328
|
+
timestamp: nowIso(),
|
|
1329
|
+
},
|
|
1330
|
+
);
|
|
1331
|
+
}
|