@littlebearapps/platform-admin-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +112 -0
  2. package/dist/index.d.ts +16 -0
  3. package/dist/index.js +89 -0
  4. package/dist/prompts.d.ts +27 -0
  5. package/dist/prompts.js +80 -0
  6. package/dist/scaffold.d.ts +5 -0
  7. package/dist/scaffold.js +65 -0
  8. package/dist/templates.d.ts +16 -0
  9. package/dist/templates.js +131 -0
  10. package/package.json +46 -0
  11. package/templates/full/migrations/006_pattern_discovery.sql +199 -0
  12. package/templates/full/migrations/007_notifications_search.sql +127 -0
  13. package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
  14. package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
  15. package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
  16. package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
  17. package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
  18. package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
  19. package/templates/full/workers/pattern-discovery.ts +661 -0
  20. package/templates/full/workers/platform-alert-router.ts +1809 -0
  21. package/templates/full/workers/platform-notifications.ts +424 -0
  22. package/templates/full/workers/platform-search.ts +480 -0
  23. package/templates/full/workers/platform-settings.ts +436 -0
  24. package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
  25. package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
  26. package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
  27. package/templates/full/wrangler.search.jsonc.hbs +16 -0
  28. package/templates/full/wrangler.settings.jsonc.hbs +23 -0
  29. package/templates/shared/README.md.hbs +69 -0
  30. package/templates/shared/config/budgets.yaml.hbs +72 -0
  31. package/templates/shared/config/services.yaml.hbs +45 -0
  32. package/templates/shared/migrations/001_core_tables.sql +117 -0
  33. package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
  34. package/templates/shared/migrations/003_feature_tracking.sql +250 -0
  35. package/templates/shared/migrations/004_settings_alerts.sql +452 -0
  36. package/templates/shared/migrations/seed.sql.hbs +4 -0
  37. package/templates/shared/package.json.hbs +21 -0
  38. package/templates/shared/scripts/sync-config.ts +242 -0
  39. package/templates/shared/tsconfig.json +12 -0
  40. package/templates/shared/workers/lib/analytics-engine.ts +357 -0
  41. package/templates/shared/workers/lib/billing.ts +293 -0
  42. package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
  43. package/templates/shared/workers/lib/control.ts +292 -0
  44. package/templates/shared/workers/lib/economics.ts +368 -0
  45. package/templates/shared/workers/lib/metrics.ts +103 -0
  46. package/templates/shared/workers/lib/platform-settings.ts +407 -0
  47. package/templates/shared/workers/lib/shared/allowances.ts +333 -0
  48. package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
  49. package/templates/shared/workers/lib/shared/types.ts +58 -0
  50. package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
  51. package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
  52. package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
  53. package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
  54. package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
  55. package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
  56. package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
  57. package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
  58. package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
  59. package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
  60. package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
  61. package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
  62. package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
  63. package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
  64. package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
  65. package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
  66. package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
  67. package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
  68. package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
  69. package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
  70. package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
  71. package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
  72. package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
  73. package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
  74. package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
  75. package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
  76. package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
  77. package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
  78. package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
  79. package/templates/shared/workers/platform-usage.ts +1915 -0
  80. package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
  81. package/templates/standard/migrations/005_error_collection.sql +162 -0
  82. package/templates/standard/workers/error-collector.ts +2670 -0
  83. package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
  84. package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
  85. package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
  86. package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
  87. package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
  88. package/templates/standard/workers/lib/error-collector/github.ts +329 -0
  89. package/templates/standard/workers/lib/error-collector/types.ts +262 -0
  90. package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
  91. package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
  92. package/templates/standard/workers/platform-sentinel.ts +1744 -0
  93. package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
  94. package/templates/standard/wrangler.sentinel.jsonc.hbs +45 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Email Health Alert Handler
3
+ *
4
+ * Processes email health check failures from platform-email-healthcheck
5
+ * and creates GitHub issues in the correct website repository.
6
+ *
7
+ * Uses existing GitHubClient and deduplication patterns from error-collector.
8
+ *
9
+ * @module workers/lib/error-collector/email-health-alerts
10
+ */
11
+
12
+ import type { Env, EmailHealthAlertEvent } from './types';
13
+ import { GitHubClient } from './github';
14
+
15
+ // TODO: Set your GitHub organisation and dashboard URL
16
+ const GITHUB_ORG = 'your-github-org';
17
+ const DASHBOARD_URL = 'https://your-dashboard.example.com';
18
+ const GATUS_URL = 'https://your-status.example.com';
19
+ const EMAIL_HEALTHCHECK_URL = 'https://platform-email-healthcheck.your-subdomain.workers.dev';
20
+
21
+ /**
22
+ * KV prefix for email health alert deduplication.
23
+ * Format: EMAIL_HEALTH:{brand}:{check_type}:{date}
24
+ * One issue per brand per check type per day maximum.
25
+ */
26
+ const EMAIL_HEALTH_PREFIX = 'EMAIL_HEALTH';
27
+
28
+ /** Labels applied to email health alert issues */
29
+ const EMAIL_HEALTH_LABELS = ['cf:email-health', 'cf:priority:p2', 'cf:auto-generated'];
30
+
31
+ /** Get today's date key in YYYY-MM-DD format (UTC) */
32
+ function getDateKey(): string {
33
+ return new Date().toISOString().slice(0, 10);
34
+ }
35
+
36
+ /**
37
+ * Check if an email health alert has already been created for this brand+check today.
38
+ * @returns Issue number if exists, null otherwise
39
+ */
40
+ async function checkDedup(
41
+ kv: KVNamespace,
42
+ brandId: string,
43
+ checkType: string
44
+ ): Promise<number | null> {
45
+ const key = `${EMAIL_HEALTH_PREFIX}:${brandId}:${checkType}:${getDateKey()}`;
46
+ const existing = await kv.get(key);
47
+ return existing ? parseInt(existing, 10) : null;
48
+ }
49
+
50
+ /** Record that an email health alert issue was created for today. */
51
+ async function setDedup(
52
+ kv: KVNamespace,
53
+ brandId: string,
54
+ checkType: string,
55
+ issueNumber: number
56
+ ): Promise<void> {
57
+ const key = `${EMAIL_HEALTH_PREFIX}:${brandId}:${checkType}:${getDateKey()}`;
58
+ // TTL of 25 hours to cover the full day plus buffer
59
+ await kv.put(key, String(issueNumber), { expirationTtl: 90000 });
60
+ }
61
+
62
+ // TODO: Map your brand IDs to display names
63
+ /** Brand display names for issue titles */
64
+ const BRAND_NAMES: Record<string, string> = {
65
+ // Example:
66
+ // mybrand: 'My Brand',
67
+ // anotherbrand: 'Another Brand',
68
+ };
69
+
70
+ // TODO: Map your brand IDs to email domains
71
+ /** Brand domain mappings for health check URLs */
72
+ const BRAND_DOMAINS: Record<string, string> = {
73
+ // Example:
74
+ // mybrand: 'mybrand.com',
75
+ // anotherbrand: 'anotherbrand.io',
76
+ };
77
+
78
+ /** Format the GitHub issue body for email health check failures. */
79
+ function formatIssueBody(event: EmailHealthAlertEvent): string {
80
+ const brandName = BRAND_NAMES[event.brand_id] ?? event.brand_id;
81
+ const brandDomain = BRAND_DOMAINS[event.brand_id] ?? `${event.brand_id}.example.com`;
82
+ const now = new Date().toISOString();
83
+
84
+ const failureRows = event.failures
85
+ .map((f) => `| \`${f.check_type}\` | ${f.error_msg} |`)
86
+ .join('\n');
87
+
88
+ return `## Email Health Check Failures
89
+
90
+ | | |
91
+ |---|---|
92
+ | **Brand** | \`${event.brand_id}\` (${brandName}) |
93
+ | **Failures** | ${event.failures.length} check(s) |
94
+ | **Run ID** | \`${event.run_id}\` |
95
+ | **Detected** | ${now} |
96
+
97
+ ### Failing Checks
98
+
99
+ | Check | Error |
100
+ |-------|-------|
101
+ ${failureRows}
102
+
103
+ ### Check Types Reference
104
+
105
+ | Check | What It Validates |
106
+ |-------|-------------------|
107
+ | \`brand_config\` | Brand exists in D1, status=active, from_email set |
108
+ | \`templates\` | confirmation + welcome email templates exist and active |
109
+ | \`confirmation_page\` | /confirmed page returns 200 on website |
110
+ | \`email_api_health\` | email.{domain}/email/_health returns ok |
111
+ | \`resend_dns\` | Resend domain verified, DKIM + SPF records verified |
112
+ | \`dmarc\` | DMARC record present with enforcing policy |
113
+ | \`recent_sends\` | At least 1 email sent in last 7 days |
114
+
115
+ ### Investigation Steps
116
+
117
+ 1. **Run manual health check** for this brand:
118
+ \`\`\`bash
119
+ curl "${EMAIL_HEALTHCHECK_URL}/healthcheck?brand=${event.brand_id}"
120
+ \`\`\`
121
+
122
+ 2. **Check health check history**:
123
+ \`\`\`bash
124
+ curl "${EMAIL_HEALTHCHECK_URL}/history?brand=${event.brand_id}&limit=20"
125
+ \`\`\`
126
+
127
+ 3. **Check email API directly**:
128
+ \`\`\`bash
129
+ curl "https://email.${brandDomain}/email/_health"
130
+ \`\`\`
131
+
132
+ 4. **Check Resend domain status** via Resend dashboard or API
133
+
134
+ 5. **Check Gatus** for platform-email-healthcheck heartbeat:
135
+ - [Gatus Status Page](${GATUS_URL})
136
+
137
+ ### Quick Links
138
+
139
+ - [Platform Dashboard](${DASHBOARD_URL})
140
+ - [Resend Dashboard](https://resend.com/domains)
141
+ - [Repository](https://github.com/${event.repository})
142
+ - [Email Health Check Worker](https://github.com/${GITHUB_ORG}/platform/blob/main/workers/platform-email-healthcheck.ts)
143
+ - [Email System Docs](https://github.com/${GITHUB_ORG}/platform/blob/main/docs/quickrefs/email-system.md)
144
+
145
+ ---
146
+ Generated by [Platform Email Health Check](https://github.com/${GITHUB_ORG}/platform/blob/main/workers/platform-email-healthcheck.ts)
147
+ `;
148
+ }
149
+
150
+ /**
151
+ * Process email health alert failures and create GitHub issues.
152
+ *
153
+ * Creates one issue per failing check type (deduped per brand+check+day).
154
+ * Returns results for each failure processed.
155
+ */
156
+ export async function processEmailHealthAlerts(
157
+ event: EmailHealthAlertEvent,
158
+ env: Env
159
+ ): Promise<{
160
+ processed: number;
161
+ skipped: number;
162
+ issues: Array<{ check_type: string; issueNumber: number; issueUrl: string }>;
163
+ skippedChecks: Array<{ check_type: string; reason: string }>;
164
+ }> {
165
+ const results = {
166
+ processed: 0,
167
+ skipped: 0,
168
+ issues: [] as Array<{ check_type: string; issueNumber: number; issueUrl: string }>,
169
+ skippedChecks: [] as Array<{ check_type: string; reason: string }>,
170
+ };
171
+
172
+ // Parse owner/repo
173
+ const [owner, repo] = event.repository.split('/');
174
+ if (!owner || !repo) {
175
+ results.skippedChecks.push(
176
+ ...event.failures.map((f) => ({
177
+ check_type: f.check_type,
178
+ reason: `Invalid repository format: ${event.repository}`,
179
+ }))
180
+ );
181
+ results.skipped = event.failures.length;
182
+ return results;
183
+ }
184
+
185
+ const github = new GitHubClient(env);
186
+ const brandName = BRAND_NAMES[event.brand_id] ?? event.brand_id;
187
+
188
+ for (const failure of event.failures) {
189
+ // Check dedup — one issue per brand per check type per day
190
+ const existingIssue = await checkDedup(env.PLATFORM_CACHE, event.brand_id, failure.check_type);
191
+ if (existingIssue) {
192
+ results.skipped++;
193
+ results.skippedChecks.push({
194
+ check_type: failure.check_type,
195
+ reason: `Issue #${existingIssue} already created today`,
196
+ });
197
+ continue;
198
+ }
199
+
200
+ try {
201
+ // Create a focused issue for this specific check failure
202
+ const issue = await github.createIssue({
203
+ owner,
204
+ repo,
205
+ title: `Email Health: ${brandName} ${failure.check_type} failing`,
206
+ body: formatIssueBody({
207
+ ...event,
208
+ failures: [failure], // Only include this specific failure
209
+ }),
210
+ labels: EMAIL_HEALTH_LABELS,
211
+ });
212
+
213
+ console.log(`Created email health issue #${issue.number} for ${event.brand_id}:${failure.check_type}`);
214
+
215
+ // Record dedup
216
+ await setDedup(env.PLATFORM_CACHE, event.brand_id, failure.check_type, issue.number);
217
+
218
+ results.processed++;
219
+ results.issues.push({
220
+ check_type: failure.check_type,
221
+ issueNumber: issue.number,
222
+ issueUrl: issue.html_url,
223
+ });
224
+ } catch (error) {
225
+ console.error(`Failed to create email health issue for ${event.brand_id}:${failure.check_type}:`, error);
226
+ results.skipped++;
227
+ results.skippedChecks.push({
228
+ check_type: failure.check_type,
229
+ reason: `GitHub API error: ${error instanceof Error ? error.message : String(error)}`,
230
+ });
231
+ }
232
+ }
233
+
234
+ // Create dashboard notification (summary for all failures)
235
+ if (results.processed > 0 && env.NOTIFICATIONS_API) {
236
+ try {
237
+ await env.NOTIFICATIONS_API.fetch(
238
+ 'https://platform-notifications.internal/notifications',
239
+ {
240
+ method: 'POST',
241
+ headers: { 'Content-Type': 'application/json' },
242
+ body: JSON.stringify({
243
+ category: 'email_health',
244
+ source: 'platform-email-healthcheck',
245
+ source_id: event.run_id,
246
+ title: `Email Health: ${brandName} has ${event.failures.length} failing check(s)`,
247
+ description: event.failures.map((f) => `${f.check_type}: ${f.error_msg}`).join('; '),
248
+ priority: 'medium',
249
+ action_url: results.issues[0]?.issueUrl,
250
+ action_label: 'View Issue',
251
+ project: 'platform',
252
+ }),
253
+ }
254
+ );
255
+ } catch (e) {
256
+ // Non-blocking
257
+ console.error('Failed to create dashboard notification:', e);
258
+ }
259
+ }
260
+
261
+ return results;
262
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Error Fingerprinting
3
+ * Creates stable hashes for error deduplication
4
+ *
5
+ * Supports both static patterns (from SDK) and dynamic patterns
6
+ * (loaded from KV/D1 at runtime via AI-assisted pattern discovery).
7
+ */
8
+
9
+ import type { TailEvent, ErrorType } from './types';
10
+ import { normalizeUrl, extractCoreMessage } from './capture';
11
+
12
+ // Re-export SDK patterns and types for backward compatibility
13
+ export {
14
+ TRANSIENT_ERROR_PATTERNS,
15
+ type TransientErrorPattern,
16
+ } from '@littlebearapps/platform-consumer-sdk/patterns';
17
+
18
+ export {
19
+ loadDynamicPatterns,
20
+ clearDynamicPatternsCache,
21
+ compileDynamicPatterns,
22
+ classifyWithDynamicPatterns,
23
+ DYNAMIC_PATTERNS_KV_KEY,
24
+ type DynamicPatternRule,
25
+ type CompiledPattern,
26
+ } from '@littlebearapps/platform-consumer-sdk/dynamic-patterns';
27
+
28
+ // Import for local use in classify/fingerprint functions
29
+ import { TRANSIENT_ERROR_PATTERNS } from '@littlebearapps/platform-consumer-sdk/patterns';
30
+ import type { CompiledPattern } from '@littlebearapps/platform-consumer-sdk/dynamic-patterns';
31
+
32
+ /** Classification result including pattern source for analytics */
33
+ export interface ClassificationResult {
34
+ category: string;
35
+ source: 'static' | 'dynamic';
36
+ patternId?: string;
37
+ }
38
+
39
+ /**
40
+ * Classify an error message into a semantic category for transient errors.
41
+ * Returns the category if matched, or null if the error should use
42
+ * standard message-based fingerprinting.
43
+ *
44
+ * Checks static patterns first (higher trust), then dynamic patterns.
45
+ *
46
+ * @example
47
+ * classifyError('[YOUTUBE_QUOTA_EXHAUSTED] Daily limit exceeded')
48
+ * // Returns: 'quota-exhausted'
49
+ *
50
+ * classifyError('TypeError: Cannot read property x')
51
+ * // Returns: null (not a transient error)
52
+ */
53
+ export function classifyError(message: string): string | null {
54
+ const result = classifyErrorWithSource(message);
55
+ return result?.category ?? null;
56
+ }
57
+
58
+ /**
59
+ * Classify an error message with source information.
60
+ * Used internally and by analytics to track dynamic pattern effectiveness.
61
+ */
62
+ export function classifyErrorWithSource(
63
+ message: string,
64
+ dynamicPatterns: CompiledPattern[] = []
65
+ ): ClassificationResult | null {
66
+ // Check static patterns first (trusted, from SDK)
67
+ for (const { pattern, category } of TRANSIENT_ERROR_PATTERNS) {
68
+ if (pattern.test(message)) {
69
+ return { category, source: 'static' };
70
+ }
71
+ }
72
+
73
+ // Check dynamic patterns (AI-suggested, human-approved)
74
+ for (const compiled of dynamicPatterns) {
75
+ if (compiled.test(message)) {
76
+ return {
77
+ category: compiled.category,
78
+ source: 'dynamic',
79
+ patternId: compiled.id,
80
+ };
81
+ }
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Check if an error is a transient (expected operational) error.
89
+ * Transient errors are expected to self-resolve and should not be
90
+ * treated as bugs or regressions.
91
+ */
92
+ export function isTransientError(message: string): boolean {
93
+ return classifyError(message) !== null;
94
+ }
95
+
96
+ /**
97
+ * Normalize dynamic values in a message to create stable fingerprints.
98
+ * Replaces numbers, UUIDs, timestamps, and other variable content with placeholders.
99
+ *
100
+ * Example:
101
+ * "Slow workflow step (durationMs: 116781, itemCount: 3)"
102
+ * -> "Slow workflow step (durationMs: {N}, itemCount: {N})"
103
+ *
104
+ * "Only 29 requests remaining!"
105
+ * -> "Only {N} requests remaining!"
106
+ */
107
+ export function normalizeDynamicValues(message: string): string {
108
+ return (
109
+ message
110
+ // Remove UUIDs (must be before numbers to avoid partial replacement)
111
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{UUID}')
112
+ // Remove hex hashes (16+ chars, e.g., correlation IDs, fingerprints)
113
+ .replace(/\b[0-9a-f]{16,}\b/gi, '{HASH}')
114
+ // Remove IPv6 addresses (e.g., 2001:0db8:85a3::8a2e:0370:7334)
115
+ .replace(/\b(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}\b/gi, '{IPV6}')
116
+ // Remove Base64-encoded strings (20+ chars to avoid false positives)
117
+ .replace(/\b[A-Za-z0-9+/]{20,}={0,2}\b/g, '{BASE64}')
118
+ // Remove ISO timestamps (2026-01-31T12:34:56.789Z)
119
+ .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[.\d]*Z?/g, '{TS}')
120
+ // Remove date strings (2026-01-31)
121
+ .replace(/\d{4}-\d{2}-\d{2}/g, '{DATE}')
122
+ // Remove numbers (must be last to avoid breaking other patterns)
123
+ .replace(/\d+/g, '{N}')
124
+ // Normalize whitespace
125
+ .replace(/\s+/g, ' ')
126
+ .trim()
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Result of fingerprint computation including classification metadata
132
+ */
133
+ export interface FingerprintResult {
134
+ fingerprint: string;
135
+ category: string | null;
136
+ normalizedMessage: string | null;
137
+ patternSource?: 'static' | 'dynamic';
138
+ dynamicPatternId?: string;
139
+ }
140
+
141
+ /** Options for computing fingerprints with dynamic pattern support */
142
+ export interface ComputeFingerprintOptions {
143
+ /** Pre-loaded dynamic patterns (optional, for performance) */
144
+ dynamicPatterns?: CompiledPattern[];
145
+ }
146
+
147
+ /**
148
+ * Compute a fingerprint for an error event
149
+ * Same fingerprint = same error = update existing issue
150
+ *
151
+ * For transient errors (quota exhaustion, rate limits, etc.), uses the
152
+ * error category instead of the message to ensure stable fingerprints
153
+ * even when external APIs return varying error messages.
154
+ *
155
+ * Supports both static patterns (from SDK) and dynamic patterns (from KV).
156
+ */
157
+ export async function computeFingerprint(
158
+ event: TailEvent,
159
+ errorType: ErrorType,
160
+ options: ComputeFingerprintOptions = {}
161
+ ): Promise<FingerprintResult> {
162
+ const components: string[] = [event.scriptName, errorType];
163
+ let classification: ClassificationResult | null = null;
164
+ let normalizedMessage: string | null = null;
165
+ const dynamicPatterns = options.dynamicPatterns || [];
166
+
167
+ // For exceptions, include exception name and either category or normalized message
168
+ if (errorType === 'exception' && event.exceptions.length > 0) {
169
+ const exc = event.exceptions[0];
170
+ components.push(exc.name);
171
+
172
+ // Check for transient error classification first (static + dynamic)
173
+ classification = classifyErrorWithSource(exc.message, dynamicPatterns);
174
+ if (classification) {
175
+ // Use stable category instead of variable message
176
+ components.push(classification.category);
177
+ normalizedMessage = normalizeDynamicValues(exc.message).slice(0, 200);
178
+ } else {
179
+ // Standard message-based fingerprinting
180
+ normalizedMessage = normalizeDynamicValues(exc.message).slice(0, 100);
181
+ components.push(normalizedMessage);
182
+ }
183
+ }
184
+
185
+ // For CPU/memory limits, just use script name + type (already in components)
186
+ // These are script-level issues, not request-specific
187
+
188
+ // For soft errors, include the normalized error message or category
189
+ if (errorType === 'soft_error') {
190
+ const errorLog = event.logs.find((l) => l.level === 'error');
191
+ if (errorLog) {
192
+ const coreMsg = extractCoreMessage(errorLog.message[0]);
193
+
194
+ // Check for transient error classification (static + dynamic)
195
+ classification = classifyErrorWithSource(coreMsg, dynamicPatterns);
196
+ if (classification) {
197
+ components.push(classification.category);
198
+ normalizedMessage = normalizeDynamicValues(coreMsg).slice(0, 200);
199
+ } else {
200
+ normalizedMessage = normalizeDynamicValues(coreMsg).slice(0, 100);
201
+ components.push(normalizedMessage);
202
+ }
203
+ }
204
+ }
205
+
206
+ // For warnings, include the normalized warning message or category
207
+ if (errorType === 'warning') {
208
+ const warnLog = event.logs.find((l) => l.level === 'warn');
209
+ if (warnLog) {
210
+ const coreMsg = extractCoreMessage(warnLog.message[0]);
211
+
212
+ // Check for transient error classification (static + dynamic)
213
+ classification = classifyErrorWithSource(coreMsg, dynamicPatterns);
214
+ if (classification) {
215
+ components.push(classification.category);
216
+ normalizedMessage = normalizeDynamicValues(coreMsg).slice(0, 200);
217
+ } else {
218
+ normalizedMessage = normalizeDynamicValues(coreMsg).slice(0, 100);
219
+ components.push(normalizedMessage);
220
+ }
221
+ }
222
+ }
223
+
224
+ // Include normalized URL for HTTP errors (helps distinguish different endpoints)
225
+ // Note: Cron/scheduled events don't have request URLs
226
+ if (event.event?.request?.url && (errorType === 'exception' || errorType === 'soft_error')) {
227
+ components.push(normalizeUrl(event.event.request.url));
228
+ }
229
+
230
+ // Create hash
231
+ const data = components.join(':');
232
+ const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data));
233
+
234
+ // Return first 32 hex chars (16 bytes)
235
+ const fingerprint = Array.from(new Uint8Array(hashBuffer))
236
+ .slice(0, 16)
237
+ .map((b) => b.toString(16).padStart(2, '0'))
238
+ .join('');
239
+
240
+ return {
241
+ fingerprint,
242
+ category: classification?.category ?? null,
243
+ normalizedMessage,
244
+ patternSource: classification?.source,
245
+ dynamicPatternId: classification?.patternId,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Generate a unique ID for a new error occurrence
251
+ */
252
+ export function generateId(): string {
253
+ const bytes = new Uint8Array(16);
254
+ crypto.getRandomValues(bytes);
255
+ return Array.from(bytes)
256
+ .map((b) => b.toString(16).padStart(2, '0'))
257
+ .join('');
258
+ }