@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.
- package/README.md +112 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +89 -0
- package/dist/prompts.d.ts +27 -0
- package/dist/prompts.js +80 -0
- package/dist/scaffold.d.ts +5 -0
- package/dist/scaffold.js +65 -0
- package/dist/templates.d.ts +16 -0
- package/dist/templates.js +131 -0
- package/package.json +46 -0
- package/templates/full/migrations/006_pattern_discovery.sql +199 -0
- package/templates/full/migrations/007_notifications_search.sql +127 -0
- package/templates/full/workers/lib/pattern-discovery/ai-prompt.ts +644 -0
- package/templates/full/workers/lib/pattern-discovery/clustering.ts +278 -0
- package/templates/full/workers/lib/pattern-discovery/shadow-evaluation.ts +603 -0
- package/templates/full/workers/lib/pattern-discovery/storage.ts +806 -0
- package/templates/full/workers/lib/pattern-discovery/types.ts +159 -0
- package/templates/full/workers/lib/pattern-discovery/validation.ts +278 -0
- package/templates/full/workers/pattern-discovery.ts +661 -0
- package/templates/full/workers/platform-alert-router.ts +1809 -0
- package/templates/full/workers/platform-notifications.ts +424 -0
- package/templates/full/workers/platform-search.ts +480 -0
- package/templates/full/workers/platform-settings.ts +436 -0
- package/templates/full/wrangler.alert-router.jsonc.hbs +34 -0
- package/templates/full/wrangler.notifications.jsonc.hbs +23 -0
- package/templates/full/wrangler.pattern-discovery.jsonc.hbs +33 -0
- package/templates/full/wrangler.search.jsonc.hbs +16 -0
- package/templates/full/wrangler.settings.jsonc.hbs +23 -0
- package/templates/shared/README.md.hbs +69 -0
- package/templates/shared/config/budgets.yaml.hbs +72 -0
- package/templates/shared/config/services.yaml.hbs +45 -0
- package/templates/shared/migrations/001_core_tables.sql +117 -0
- package/templates/shared/migrations/002_usage_warehouse.sql +830 -0
- package/templates/shared/migrations/003_feature_tracking.sql +250 -0
- package/templates/shared/migrations/004_settings_alerts.sql +452 -0
- package/templates/shared/migrations/seed.sql.hbs +4 -0
- package/templates/shared/package.json.hbs +21 -0
- package/templates/shared/scripts/sync-config.ts +242 -0
- package/templates/shared/tsconfig.json +12 -0
- package/templates/shared/workers/lib/analytics-engine.ts +357 -0
- package/templates/shared/workers/lib/billing.ts +293 -0
- package/templates/shared/workers/lib/circuit-breaker-middleware.ts +25 -0
- package/templates/shared/workers/lib/control.ts +292 -0
- package/templates/shared/workers/lib/economics.ts +368 -0
- package/templates/shared/workers/lib/metrics.ts +103 -0
- package/templates/shared/workers/lib/platform-settings.ts +407 -0
- package/templates/shared/workers/lib/shared/allowances.ts +333 -0
- package/templates/shared/workers/lib/shared/cloudflare.ts +1362 -0
- package/templates/shared/workers/lib/shared/types.ts +58 -0
- package/templates/shared/workers/lib/telemetry-sampling.ts +360 -0
- package/templates/shared/workers/lib/usage/collectors/example.ts +96 -0
- package/templates/shared/workers/lib/usage/collectors/index.ts +128 -0
- package/templates/shared/workers/lib/usage/handlers/audit.ts +306 -0
- package/templates/shared/workers/lib/usage/handlers/backfill.ts +845 -0
- package/templates/shared/workers/lib/usage/handlers/behavioral.ts +429 -0
- package/templates/shared/workers/lib/usage/handlers/data-queries.ts +507 -0
- package/templates/shared/workers/lib/usage/handlers/dlq-admin.ts +364 -0
- package/templates/shared/workers/lib/usage/handlers/health-trends.ts +222 -0
- package/templates/shared/workers/lib/usage/handlers/index.ts +35 -0
- package/templates/shared/workers/lib/usage/handlers/usage-admin.ts +421 -0
- package/templates/shared/workers/lib/usage/handlers/usage-features.ts +1262 -0
- package/templates/shared/workers/lib/usage/handlers/usage-metrics.ts +2420 -0
- package/templates/shared/workers/lib/usage/handlers/usage-settings.ts +610 -0
- package/templates/shared/workers/lib/usage/queue/budget-enforcement.ts +1032 -0
- package/templates/shared/workers/lib/usage/queue/cost-budget-enforcement.ts +128 -0
- package/templates/shared/workers/lib/usage/queue/cost-calculator.ts +77 -0
- package/templates/shared/workers/lib/usage/queue/dlq-handler.ts +161 -0
- package/templates/shared/workers/lib/usage/queue/index.ts +19 -0
- package/templates/shared/workers/lib/usage/queue/telemetry-processor.ts +790 -0
- package/templates/shared/workers/lib/usage/scheduled/anomaly-detection.ts +732 -0
- package/templates/shared/workers/lib/usage/scheduled/data-collection.ts +956 -0
- package/templates/shared/workers/lib/usage/scheduled/error-digest.ts +343 -0
- package/templates/shared/workers/lib/usage/scheduled/index.ts +18 -0
- package/templates/shared/workers/lib/usage/scheduled/rollups.ts +1561 -0
- package/templates/shared/workers/lib/usage/shared/constants.ts +362 -0
- package/templates/shared/workers/lib/usage/shared/index.ts +14 -0
- package/templates/shared/workers/lib/usage/shared/types.ts +1066 -0
- package/templates/shared/workers/lib/usage/shared/utils.ts +795 -0
- package/templates/shared/workers/platform-usage.ts +1915 -0
- package/templates/shared/wrangler.usage.jsonc.hbs +58 -0
- package/templates/standard/migrations/005_error_collection.sql +162 -0
- package/templates/standard/workers/error-collector.ts +2670 -0
- package/templates/standard/workers/lib/error-collector/capture.ts +213 -0
- package/templates/standard/workers/lib/error-collector/digest.ts +448 -0
- package/templates/standard/workers/lib/error-collector/email-health-alerts.ts +262 -0
- package/templates/standard/workers/lib/error-collector/fingerprint.ts +258 -0
- package/templates/standard/workers/lib/error-collector/gap-alerts.ts +293 -0
- package/templates/standard/workers/lib/error-collector/github.ts +329 -0
- package/templates/standard/workers/lib/error-collector/types.ts +262 -0
- package/templates/standard/workers/lib/sentinel/gap-detection.ts +734 -0
- package/templates/standard/workers/lib/shared/slack-alerts.ts +585 -0
- package/templates/standard/workers/platform-sentinel.ts +1744 -0
- package/templates/standard/wrangler.error-collector.jsonc.hbs +44 -0
- 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
|
+
}
|