@agent-native/core 0.161.1 → 0.161.2

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 (26) hide show
  1. package/corpus/templates/analytics/actions/compose-dashboard.ts +5 -2
  2. package/corpus/templates/analytics/actions/export-dashboard-panel-to-google-sheet.ts +11 -5
  3. package/corpus/templates/analytics/actions/migrate-first-party-analytics-to-bigquery.ts +89 -2
  4. package/corpus/templates/analytics/actions/update-dashboard.ts +14 -2
  5. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +6 -0
  6. package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +89 -41
  7. package/corpus/templates/analytics/server/lib/dashboard-panel-source-resolver.ts +8 -3
  8. package/corpus/templates/analytics/server/lib/error-capture.ts +6 -0
  9. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +187 -44
  10. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +44 -16
  11. package/dist/agent/engine/builder-engine.js +7 -4
  12. package/dist/agent/engine/types.d.ts +10 -0
  13. package/dist/agent/engine/types.js +3 -0
  14. package/dist/agent/production-agent.js +1 -0
  15. package/dist/agent/run-manager.js +8 -0
  16. package/dist/collab/struct-routes.d.ts +1 -1
  17. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  18. package/dist/observability/routes.d.ts +3 -3
  19. package/dist/provider-api/actions/custom-provider-registration.d.ts +2 -2
  20. package/dist/resources/handlers.d.ts +1 -1
  21. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  22. package/dist/server/release-migrations.js +4 -0
  23. package/dist/server/transcribe-voice.d.ts +1 -1
  24. package/dist/workspace-connections/migrations.d.ts +18 -0
  25. package/dist/workspace-connections/migrations.js +153 -0
  26. package/package.json +3 -3
@@ -13,7 +13,7 @@ import {
13
13
  upsertDashboard,
14
14
  upsertDashboardWithRetry,
15
15
  } from "../server/lib/dashboards-store";
16
- import { validateFirstPartyAnalyticsSql } from "../server/lib/first-party-analytics.js";
16
+ import { validateFirstPartyAnalyticsSqlForScope } from "../server/lib/first-party-analytics.js";
17
17
  import {
18
18
  buildFirstPartyDashboardFilters,
19
19
  buildPanel,
@@ -212,7 +212,10 @@ export default defineAction({
212
212
  invalidMetrics.push({ metric: req.metric, reason: timeScopeError });
213
213
  continue;
214
214
  }
215
- validateFirstPartyAnalyticsSql(panel.sql);
215
+ await validateFirstPartyAnalyticsSqlForScope(panel.sql, {
216
+ userEmail: ctx.email,
217
+ orgId: ctx.orgId,
218
+ });
216
219
  } catch (e: any) {
217
220
  invalidMetrics.push({
218
221
  metric: req.metric,
@@ -31,14 +31,20 @@ function asPanels(config: Record<string, unknown>): DashboardPanel[] {
31
31
  : [];
32
32
  }
33
33
 
34
- function missingKeyMessage(result: unknown): string | null {
34
+ /**
35
+ * Any structured panel failure (`missing_api_key`, `unsupported_by_backend`)
36
+ * carries its own explanation. Surface that rather than the generic
37
+ * invalid-result message below, which reads as a bug in the export.
38
+ */
39
+ function panelFailureMessage(result: unknown): string | null {
35
40
  if (!result || typeof result !== "object" || Array.isArray(result)) {
36
41
  return null;
37
42
  }
38
43
  const value = result as { error?: unknown; message?: unknown };
39
- return value.error === "missing_api_key" && typeof value.message === "string"
44
+ if (typeof value.error !== "string" || !value.error) return null;
45
+ return typeof value.message === "string" && value.message
40
46
  ? value.message
41
- : null;
47
+ : value.error;
42
48
  }
43
49
 
44
50
  export default defineAction({
@@ -97,8 +103,8 @@ export default defineAction({
97
103
  { source: panel.source, query },
98
104
  context,
99
105
  );
100
- const missing = missingKeyMessage(result);
101
- if (missing) throw new Error(missing);
106
+ const failure = panelFailureMessage(result);
107
+ if (failure) throw new Error(failure);
102
108
  if (
103
109
  !result ||
104
110
  typeof result !== "object" ||
@@ -9,13 +9,79 @@ import {
9
9
  getFirstPartyAnalyticsBigQueryBackfillJob,
10
10
  queueFirstPartyAnalyticsBigQueryBackfill,
11
11
  } from "../server/jobs/analytics-bigquery-backfill.js";
12
+ import { listDashboards } from "../server/lib/dashboards-store.js";
12
13
  import { requireAnalyticsAdminContext } from "../server/lib/db-admin-connections.js";
13
14
  import {
14
15
  assertFirstPartyAnalyticsBigQueryReady,
16
+ assertFirstPartyAnalyticsBigQuerySql,
17
+ FirstPartyAnalyticsUnsupportedSqlError,
18
+ type FirstPartyAnalyticsScope,
15
19
  getFirstPartyAnalyticsBackend,
16
20
  saveFirstPartyAnalyticsBackend,
17
21
  } from "../server/lib/first-party-analytics-backend.js";
18
22
 
23
+ interface UnrunnablePanel {
24
+ dashboardId: string;
25
+ dashboardName: string;
26
+ panelId: string;
27
+ panelTitle: string;
28
+ reason: string;
29
+ }
30
+
31
+ /**
32
+ * Stored first-party panels the BigQuery sink cannot run. Cutover only flips a
33
+ * setting — it never rewrites saved SQL — so every panel listed here starts
34
+ * failing the moment the flip lands. Surfacing the list is the whole point: the
35
+ * alternative is the org discovering it as broken panels afterwards.
36
+ */
37
+ async function findPanelsUnrunnableOnBigQuery(
38
+ scope: FirstPartyAnalyticsScope,
39
+ ): Promise<UnrunnablePanel[]> {
40
+ const dashboards = await listDashboards(
41
+ { email: scope.userEmail, orgId: scope.orgId },
42
+ { kind: "sql", archived: "all" },
43
+ );
44
+ const unrunnable: UnrunnablePanel[] = [];
45
+ for (const dashboard of dashboards) {
46
+ const panels = Array.isArray(dashboard.config?.panels)
47
+ ? dashboard.config.panels
48
+ : [];
49
+ for (const raw of panels) {
50
+ const panel = raw as Record<string, unknown>;
51
+ if (panel?.source !== "first-party") continue;
52
+ if (typeof panel.sql !== "string" || !panel.sql.trim()) continue;
53
+ try {
54
+ // Stored `{{var}}` tokens are left uninterpolated on purpose: the
55
+ // translator rejects syntax, not values, and filter values are never
56
+ // syntax. Interpolating here would drag the dashboard-save module — and
57
+ // its whole import chain — into this action.
58
+ assertFirstPartyAnalyticsBigQuerySql(panel.sql);
59
+ } catch (error) {
60
+ unrunnable.push({
61
+ dashboardId: dashboard.id,
62
+ dashboardName: dashboard.title,
63
+ panelId: typeof panel.id === "string" ? panel.id : "(unnamed)",
64
+ panelTitle: typeof panel.title === "string" ? panel.title : "",
65
+ reason:
66
+ error instanceof FirstPartyAnalyticsUnsupportedSqlError
67
+ ? `uses ${error.construct}`
68
+ : `${(error as Error)?.message ?? String(error)}`,
69
+ });
70
+ }
71
+ }
72
+ }
73
+ return unrunnable;
74
+ }
75
+
76
+ function describeUnrunnablePanels(panels: UnrunnablePanel[]): string {
77
+ return panels
78
+ .map(
79
+ (panel) =>
80
+ `${panel.dashboardId}/${panel.panelId} "${panel.panelTitle}" ${panel.reason}`,
81
+ )
82
+ .join("; ");
83
+ }
84
+
19
85
  async function resolveScope() {
20
86
  const userEmail = getRequestUserEmail();
21
87
  if (!userEmail) throw new Error("no authenticated user");
@@ -48,15 +114,21 @@ const migrationSchema = z.object({
48
114
  "Optional per-page limit used when mode is prepare; for an existing job, it can only increase the bounded batch size. The durable worker then processes non-overlapping UTC time shards in parallel.",
49
115
  ),
50
116
  confirm: z.boolean().optional(),
117
+ acknowledgeUnrunnablePanels: z
118
+ .boolean()
119
+ .optional()
120
+ .describe(
121
+ "Cut over even though status reported saved first-party panels the BigQuery backend cannot run. Those panels render an unsupported-backend state until their SQL is rewritten; nothing migrates or rewrites them.",
122
+ ),
51
123
  });
52
124
 
53
125
  export default defineAction({
54
126
  description:
55
- "Production migration for the current Analytics organization. Run prepare to validate the configured BigQuery table, enter dual-write mode, and enqueue a durable worker. The worker creates non-overlapping UTC time shards, processes the newest shards first with bounded parallel leases, persists per-shard cursors, and pauses on database pressure. Call status or backfill to inspect progress, then call cutover with confirm=true only after the worker reports completed. New /track events stay in Postgres during dual-write, so a BigQuery outage does not silently lose live data. Cutover is the only step that stops first-party event and rollup writes to Postgres; public-key metadata, derived exception issues, and session-replay data remain in the SQL store.",
127
+ "Production migration for the current Analytics organization. Run prepare to validate the configured BigQuery table, enter dual-write mode, and enqueue a durable worker. The worker creates non-overlapping UTC time shards, processes the newest shards first with bounded parallel leases, persists per-shard cursors, and pauses on database pressure. Call status or backfill to inspect progress, then call cutover with confirm=true only after the worker reports completed. status and cutover both list saved first-party dashboard panels whose SQL the BigQuery backend cannot run; cutover refuses to flip while any exist unless acknowledgeUnrunnablePanels=true, because the flip never rewrites stored SQL. New /track events stay in Postgres during dual-write, so a BigQuery outage does not silently lose live data. Cutover is the only step that stops first-party event and rollup writes to Postgres; public-key metadata, derived exception issues, and session-replay data remain in the SQL store.",
56
128
  schema: migrationSchema,
57
129
  agentTool: false,
58
130
  needsApproval: ({ mode }) => mode === "cutover",
59
- run: async ({ mode, table, limit, confirm }) => {
131
+ run: async ({ mode, table, limit, confirm, acknowledgeUnrunnablePanels }) => {
60
132
  const scope = await resolveScope();
61
133
  const current = await getFirstPartyAnalyticsBackend(scope);
62
134
  const configuredTable = table ?? current.table;
@@ -70,8 +142,13 @@ export default defineAction({
70
142
  current.sink === "postgres"
71
143
  ? null
72
144
  : await getFirstPartyAnalyticsBigQueryBackfillJob(scope);
145
+ const unrunnablePanels =
146
+ current.sink === "bigquery"
147
+ ? []
148
+ : await findPanelsUnrunnableOnBigQuery(scope);
73
149
  return {
74
150
  ...current,
151
+ unrunnablePanels,
75
152
  backfillCursor:
76
153
  current.sink === "dual"
77
154
  ? (job?.cursor ?? null)
@@ -171,6 +248,15 @@ export default defineAction({
171
248
  );
172
249
  }
173
250
  const ready = await assertFirstPartyAnalyticsBigQueryReady(current.table);
251
+ const unrunnablePanels = await findPanelsUnrunnableOnBigQuery(scope);
252
+ if (unrunnablePanels.length && !acknowledgeUnrunnablePanels) {
253
+ throw Object.assign(
254
+ new Error(
255
+ `${unrunnablePanels.length} saved first-party panel(s) cannot run on BigQuery and would start failing at cutover: ${describeUnrunnablePanels(unrunnablePanels)}. Rewrite them in BigQuery-compatible SQL, or pass acknowledgeUnrunnablePanels=true to cut over and leave them showing an unsupported-backend state.`,
256
+ ),
257
+ { unrunnablePanels },
258
+ );
259
+ }
174
260
  await saveFirstPartyAnalyticsBackend(scope, {
175
261
  sink: "bigquery",
176
262
  table: ready.table.fullyQualified,
@@ -182,6 +268,7 @@ export default defineAction({
182
268
  table: ready.table.fullyQualified,
183
269
  existingBigQueryRows: ready.rowCount,
184
270
  postgresEventWrites: "stopped",
271
+ unrunnablePanels,
185
272
  next: "verify dashboards",
186
273
  };
187
274
  },
@@ -15,7 +15,8 @@ import {
15
15
  upsertDashboardWithRetry,
16
16
  } from "../server/lib/dashboards-store";
17
17
  import { parseDemoDescriptor } from "../server/lib/demo-source";
18
- import { validateFirstPartyAnalyticsSql } from "../server/lib/first-party-analytics.js";
18
+ import { FirstPartyAnalyticsUnsupportedSqlError } from "../server/lib/first-party-analytics-backend.js";
19
+ import { validateFirstPartyAnalyticsSqlForScope } from "../server/lib/first-party-analytics.js";
19
20
  import { DASHBOARD_SQL_VALIDATION_TIMEOUT_MS } from "../shared/dashboard-report-timeouts.js";
20
21
  import {
21
22
  applyPanelOrder,
@@ -391,6 +392,11 @@ export function validateDashboardConfig(
391
392
 
392
393
  const MAX_CONCURRENT_SQL_VALIDATIONS = 8;
393
394
 
395
+ function firstPartyScope() {
396
+ const { email, orgId } = resolveScope();
397
+ return { userEmail: email, orgId };
398
+ }
399
+
394
400
  export interface ValidatePanelSqlOptions {
395
401
  signal?: AbortSignal;
396
402
  }
@@ -445,7 +451,10 @@ export async function validatePanelSql(
445
451
  i,
446
452
  );
447
453
  if (timeScopeError) return timeScopeError;
448
- validateFirstPartyAnalyticsSql(interpolate(raw, vars));
454
+ await validateFirstPartyAnalyticsSqlForScope(
455
+ interpolate(raw, vars),
456
+ firstPartyScope(),
457
+ );
449
458
  } catch (e: any) {
450
459
  if (
451
460
  typeof e?.message === "string" &&
@@ -453,6 +462,9 @@ export async function validatePanelSql(
453
462
  ) {
454
463
  return e.message;
455
464
  }
465
+ if (e instanceof FirstPartyAnalyticsUnsupportedSqlError) {
466
+ return `panel[${i}] "${p.title || p.id}" cannot run on this scope's active data backend (BigQuery) because its SQL uses ${e.construct}. Rewrite it with BigQuery-compatible SQL, or move the scope back to the PostgreSQL backend.`;
467
+ }
456
468
  return `panel[${i}] "${p.title || p.id}" first-party analytics SQL is invalid: ${e?.message ?? e}`;
457
469
  }
458
470
  }
@@ -104,6 +104,12 @@ function parseProgramDescriptor(sql: string): {
104
104
  paramsText: string;
105
105
  } {
106
106
  if (!sql.trim()) return { programId: "", paramsText: "" };
107
+ // A panel may be stored as the bare program id — the server accepts that as a
108
+ // complete descriptor, so the editor has to show it rather than blanking the
109
+ // picker on a JSON.parse it was never going to satisfy.
110
+ if (/^dp_[A-Za-z0-9]+$/.test(sql.trim())) {
111
+ return { programId: sql.trim(), paramsText: "" };
112
+ }
107
113
  try {
108
114
  const parsed = JSON.parse(sql) as { programId?: unknown; params?: unknown };
109
115
  const programId =
@@ -10,6 +10,7 @@ import { getUserSegmentation, queryEvents } from "./amplitude";
10
10
  import { runQuery } from "./bigquery";
11
11
  import { runDemoPanel, serializeDemoDescriptorInput } from "./demo-source";
12
12
  import { queryFirstPartyAnalytics } from "./first-party-analytics";
13
+ import { FirstPartyAnalyticsUnsupportedSqlError } from "./first-party-analytics-backend";
13
14
  import { runReport } from "./google-analytics";
14
15
  import {
15
16
  runPrometheusPanel,
@@ -36,6 +37,19 @@ export interface DashboardPanelQueryResult {
36
37
  bytesProcessed?: number;
37
38
  }
38
39
 
40
+ /**
41
+ * A stored panel the active data backend cannot run at all. It carries no
42
+ * `rows` on purpose: an empty result set would be indistinguishable from a
43
+ * query that legitimately matched nothing, and this panel matched nothing
44
+ * because it never ran.
45
+ */
46
+ export interface UnsupportedBackendResponse {
47
+ error: "unsupported_by_backend";
48
+ backend: "bigquery";
49
+ construct: string;
50
+ message: string;
51
+ }
52
+
39
53
  export function isDashboardPanelSource(
40
54
  value: unknown,
41
55
  ): value is DashboardPanelSource {
@@ -45,43 +59,48 @@ export function isDashboardPanelSource(
45
59
  );
46
60
  }
47
61
 
62
+ export interface ProgramDescriptor {
63
+ programId: string;
64
+ params?: Record<string, unknown>;
65
+ }
66
+
67
+ /** Stored data program id: `dp_` + a random hex id. */
68
+ const PROGRAM_ID_PATTERN = /^dp_[A-Za-z0-9]+$/;
69
+
48
70
  /**
49
71
  * program panels carry a JSON blob in `sql` describing which stored data
50
72
  * program to run and with what params. Shape:
51
73
  * { programId: string; params?: Record<string, unknown> }.
74
+ *
75
+ * A bare program id is part of that grammar, not a fallback: with no params the
76
+ * id IS the whole descriptor, and it is what every caller reaches for first.
77
+ * Writer and reader both resolve through here so they cannot disagree — the
78
+ * writer used to pass any string straight through while the reader required
79
+ * JSON, so a panel saved as `dp_01c5e3d...` threw `is not valid JSON` on every
80
+ * render instead of rendering. Anything that is neither a descriptor nor an id
81
+ * still throws; an unreadable panel must never resolve to an empty one.
52
82
  */
53
- export function serializeProgramDescriptorInput(raw: unknown): string {
54
- if (typeof raw === "string") return raw;
55
- if (raw && typeof raw === "object" && !Array.isArray(raw)) {
56
- const obj = raw as Record<string, unknown>;
57
- if (typeof obj.programId !== "string" || !obj.programId.trim()) {
83
+ export function coerceProgramDescriptor(raw: unknown): ProgramDescriptor {
84
+ if (typeof raw === "string") {
85
+ const trimmed = raw.trim();
86
+ if (!trimmed) {
58
87
  throw new Error("program panel descriptor requires a 'programId' field");
59
88
  }
60
- return JSON.stringify(raw);
61
- }
62
- throw new Error(
63
- "program panel sql must be a JSON string or object with 'programId'",
64
- );
65
- }
66
-
67
- export interface ProgramDescriptor {
68
- programId: string;
69
- params?: Record<string, unknown>;
70
- }
71
-
72
- function parseProgramDescriptor(raw: string): ProgramDescriptor {
73
- let parsed: unknown;
74
- try {
75
- parsed = JSON.parse(raw);
76
- } catch (err: any) {
77
- throw new Error(
78
- `program panel sql must be a JSON object: ${err?.message ?? err}`,
79
- );
89
+ if (PROGRAM_ID_PATTERN.test(trimmed)) return { programId: trimmed };
90
+ let parsed: unknown;
91
+ try {
92
+ parsed = JSON.parse(trimmed);
93
+ } catch (err: any) {
94
+ throw new Error(
95
+ `program panel sql must be a program id or a JSON object: ${err?.message ?? err}`,
96
+ );
97
+ }
98
+ return coerceProgramDescriptor(parsed);
80
99
  }
81
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
82
- throw new Error("program panel sql must be a JSON object");
100
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
101
+ throw new Error("program panel sql must be a program id or a JSON object");
83
102
  }
84
- const obj = parsed as Record<string, unknown>;
103
+ const obj = raw as Record<string, unknown>;
85
104
  if (typeof obj.programId !== "string" || !obj.programId.trim()) {
86
105
  throw new Error("program panel descriptor requires a 'programId' field");
87
106
  }
@@ -89,7 +108,15 @@ function parseProgramDescriptor(raw: string): ProgramDescriptor {
89
108
  obj.params && typeof obj.params === "object" && !Array.isArray(obj.params)
90
109
  ? (obj.params as Record<string, unknown>)
91
110
  : undefined;
92
- return { programId: obj.programId, params };
111
+ return { programId: obj.programId.trim(), ...(params ? { params } : {}) };
112
+ }
113
+
114
+ export function serializeProgramDescriptorInput(raw: unknown): string {
115
+ return JSON.stringify(coerceProgramDescriptor(raw));
116
+ }
117
+
118
+ function parseProgramDescriptor(raw: string): ProgramDescriptor {
119
+ return coerceProgramDescriptor(raw);
93
120
  }
94
121
 
95
122
  export function normalizeDashboardPanelQuery(
@@ -406,7 +433,9 @@ export async function runDashboardPanelQuery(args: {
406
433
  query: string;
407
434
  ctx: CredentialContext;
408
435
  timeoutMs?: number;
409
- }): Promise<DashboardPanelQueryResult | MissingKeyResponse> {
436
+ }): Promise<
437
+ DashboardPanelQueryResult | MissingKeyResponse | UnsupportedBackendResponse
438
+ > {
410
439
  const { source, query, ctx, timeoutMs } = args;
411
440
 
412
441
  if (source === "bigquery") {
@@ -452,17 +481,36 @@ export async function runDashboardPanelQuery(args: {
452
481
  }
453
482
 
454
483
  if (source === "first-party") {
455
- return await queryFirstPartyAnalytics(
456
- query,
457
- {
458
- userEmail: ctx.userEmail,
459
- orgId: ctx.orgId ?? null,
460
- },
461
- {
462
- cache: true,
463
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
464
- },
465
- );
484
+ try {
485
+ return await queryFirstPartyAnalytics(
486
+ query,
487
+ {
488
+ userEmail: ctx.userEmail,
489
+ orgId: ctx.orgId ?? null,
490
+ },
491
+ {
492
+ cache: true,
493
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
494
+ },
495
+ );
496
+ } catch (error) {
497
+ // Only the "no BigQuery equivalent exists" failure becomes a rendered
498
+ // state. Every other failure — timeout, permission, provider outage —
499
+ // still throws, because those are retryable and must not read to the
500
+ // user as a permanent property of the panel.
501
+ if (!(error instanceof FirstPartyAnalyticsUnsupportedSqlError))
502
+ throw error;
503
+ console.error(
504
+ "[first-party-analytics] Panel SQL has no BigQuery translation:",
505
+ error,
506
+ );
507
+ return {
508
+ error: "unsupported_by_backend",
509
+ backend: "bigquery",
510
+ construct: error.construct,
511
+ message: `This panel can't run on your current data backend (BigQuery) because its SQL uses ${error.construct}. Edit the panel's SQL, or switch the backend back to PostgreSQL.`,
512
+ };
513
+ }
466
514
  }
467
515
 
468
516
  if (source === "demo") {
@@ -12,8 +12,13 @@ import {
12
12
  type DashboardPanelQueryResult,
13
13
  type DashboardPanelSource,
14
14
  runDashboardPanelQuery,
15
+ type UnsupportedBackendResponse,
15
16
  } from "./dashboard-panel-query";
16
17
 
18
+ type AnalyticsPanelSourceFailure =
19
+ | MissingKeyResponse
20
+ | UnsupportedBackendResponse;
21
+
17
22
  type AnalyticsPanelSourceResolver = PanelSourceResolver<
18
23
  DashboardPanelSource,
19
24
  CredentialContext
@@ -35,7 +40,7 @@ function createResolver(
35
40
  query: request.query,
36
41
  ctx: context,
37
42
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
38
- })) as DashboardPanelQueryResult | MissingKeyResponse;
43
+ })) as DashboardPanelQueryResult | AnalyticsPanelSourceFailure;
39
44
  },
40
45
  };
41
46
  }
@@ -51,8 +56,8 @@ const registry = createPanelSourceResolverRegistry<
51
56
  export async function resolveAnalyticsPanelSource(
52
57
  request: AnalyticsPanelSourceRequest,
53
58
  context: CredentialContext,
54
- ): Promise<PanelSourceResult | MissingKeyResponse> {
59
+ ): Promise<PanelSourceResult | AnalyticsPanelSourceFailure> {
55
60
  return registry.resolve(request, context) as Promise<
56
- PanelSourceResult | MissingKeyResponse
61
+ PanelSourceResult | AnalyticsPanelSourceFailure
57
62
  >;
58
63
  }
@@ -396,6 +396,12 @@ function normalizeMessageForFingerprint(message: string): string {
396
396
  .replace(/(['"`])[^\s'"`]*\1/g, "<str>")
397
397
  .replace(/(?:\/[\w.@%+-]+){2,}\/?/g, "<path>")
398
398
  .replace(/0x[0-9a-f]+/gi, "<hex>")
399
+ // Bare hex ids (request ids, trace ids, the gateway's "ERROR ID: ...")
400
+ // carry no dashes and no 0x, so they used to fall through to the digit
401
+ // rule below — which keeps the a-f nibbles and gives every occurrence its
402
+ // own key. An outage then renders as N unrelated issues of count 1.
403
+ // Requiring a letter AND a digit keeps prose and identifiers out.
404
+ .replace(/\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*\d)[0-9a-f]{8,}\b/gi, "<hex>")
399
405
  // Not \b\d+\b: a unit suffix ("8000ms") keeps the digits word-adjacent, so
400
406
  // a bounded rule leaves every timeout value in its own group.
401
407
  .replace(/\d+/g, "<n>")