@volter/twin-sentry 0.1.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/LICENSE +202 -0
- package/README.md +142 -0
- package/client/sentry-mirror.css +99 -0
- package/client/sentry-mirror.tsx +352 -0
- package/dist/client/sentry-mirror.bundle.js +321 -0
- package/dist/client/sentry-mirror.css +99 -0
- package/dist/client/sentry-mirror.d.ts +17 -0
- package/dist/client/sentry-mirror.js +156 -0
- package/dist/client/sentry-mirror.tsx +352 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +36 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +75 -0
- package/dist/src/sentry-budget.d.ts +50 -0
- package/dist/src/sentry-budget.js +145 -0
- package/dist/src/sentry-capabilities.d.ts +3 -0
- package/dist/src/sentry-capabilities.js +1180 -0
- package/dist/src/sentry-conformance.d.ts +22 -0
- package/dist/src/sentry-conformance.js +97 -0
- package/dist/src/sentry-connector.d.ts +77 -0
- package/dist/src/sentry-connector.js +226 -0
- package/dist/src/sentry-events.d.ts +54 -0
- package/dist/src/sentry-events.js +131 -0
- package/dist/src/sentry-ingest.d.ts +137 -0
- package/dist/src/sentry-ingest.js +387 -0
- package/dist/src/sentry-mirror-ui.d.ts +38 -0
- package/dist/src/sentry-mirror-ui.js +162 -0
- package/dist/src/sentry-perform-harness.d.ts +9 -0
- package/dist/src/sentry-perform-harness.js +20 -0
- package/dist/src/sentry-server.d.ts +14 -0
- package/dist/src/sentry-server.js +27 -0
- package/dist/src/sentry-twin.d.ts +17 -0
- package/dist/src/sentry-twin.js +1739 -0
- package/dist/test-fixtures/sentry-openapi-operations.SOURCE.md +24 -0
- package/dist/test-fixtures/sentry-openapi-operations.json +1837 -0
- package/package.json +75 -0
- package/src/cli.ts +34 -0
- package/src/index.ts +145 -0
- package/src/sentry-budget.ts +171 -0
- package/src/sentry-capabilities.ts +1222 -0
- package/src/sentry-conformance.ts +110 -0
- package/src/sentry-connector.ts +260 -0
- package/src/sentry-events.ts +171 -0
- package/src/sentry-ingest.ts +471 -0
- package/src/sentry-mirror-ui.ts +162 -0
- package/src/sentry-perform-harness.ts +19 -0
- package/src/sentry-server.ts +35 -0
- package/src/sentry-twin.ts +1615 -0
- package/test-fixtures/sentry-openapi-operations.SOURCE.md +24 -0
- package/test-fixtures/sentry-openapi-operations.json +1837 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
// Sentry INGESTION — the SDK side of Sentry (the "store"/"envelope" endpoints the
|
|
2
|
+
// `@sentry/node` SDK POSTs captured events to). This is the CORE of the twin: an
|
|
3
|
+
// unmodified `Sentry.captureException()` configured with the twin's DSN lands an event
|
|
4
|
+
// here, the twin GROUPS it into an ISSUE by a deterministic fingerprint, increments the
|
|
5
|
+
// issue's count + first/last seen, persists the event, and returns the event id — exactly
|
|
6
|
+
// like real Sentry's relay/ingest pipeline.
|
|
7
|
+
//
|
|
8
|
+
// State lives in the kernel action log (D1): every event AND its derived issue is written
|
|
9
|
+
// as a local action via applyTwinWrite; reads (the Web API) are the projection. No real
|
|
10
|
+
// Sentry is ever called (D4) — ingestion is fully local/offline.
|
|
11
|
+
//
|
|
12
|
+
// Auth: Sentry authenticates ingestion by the DSN's PUBLIC KEY, supplied either in the
|
|
13
|
+
// `X-Sentry-Auth` header (`Sentry sentry_key=<publicKey>,...`) or the `?sentry_key=`
|
|
14
|
+
// query param, or embedded in the envelope header's `dsn`. The twin validates that a
|
|
15
|
+
// project with that public key exists (faithful 401 otherwise). The store/envelope path
|
|
16
|
+
// is unauthenticated by the SECRET key (that's the modern DSN model — public key only).
|
|
17
|
+
import { applyTwinWrite, projectResources } from '@volter/world-core';
|
|
18
|
+
import type { TwinResource } from '@volter/world-core';
|
|
19
|
+
|
|
20
|
+
const SERVICE = 'sentry';
|
|
21
|
+
|
|
22
|
+
export type SentryEventPayload = {
|
|
23
|
+
event_id?: string;
|
|
24
|
+
level?: string;
|
|
25
|
+
platform?: string;
|
|
26
|
+
message?: string | { message?: string; formatted?: string };
|
|
27
|
+
logentry?: { message?: string; formatted?: string };
|
|
28
|
+
transaction?: string;
|
|
29
|
+
culprit?: string;
|
|
30
|
+
environment?: string;
|
|
31
|
+
release?: string;
|
|
32
|
+
server_name?: string;
|
|
33
|
+
timestamp?: number | string;
|
|
34
|
+
tags?: Record<string, unknown> | Array<[string, string]>;
|
|
35
|
+
exception?: { values?: SentryExceptionValue[] } | SentryExceptionValue[];
|
|
36
|
+
[k: string]: unknown;
|
|
37
|
+
};
|
|
38
|
+
export type SentryExceptionValue = {
|
|
39
|
+
type?: string;
|
|
40
|
+
value?: string;
|
|
41
|
+
module?: string;
|
|
42
|
+
stacktrace?: { frames?: SentryFrame[] };
|
|
43
|
+
};
|
|
44
|
+
export type SentryFrame = {
|
|
45
|
+
filename?: string;
|
|
46
|
+
function?: string;
|
|
47
|
+
module?: string;
|
|
48
|
+
lineno?: number;
|
|
49
|
+
colno?: number;
|
|
50
|
+
in_app?: boolean;
|
|
51
|
+
abs_path?: string;
|
|
52
|
+
[k: string]: unknown;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type IngestResult = { status: number; body: unknown };
|
|
56
|
+
|
|
57
|
+
// ── DSN public key extraction (auth) ─────────────────────────────────────────────
|
|
58
|
+
// The SDK sends the public key in one of three places. We accept all three, mirroring
|
|
59
|
+
// real Sentry's auth resolution order: header → query → envelope-header dsn.
|
|
60
|
+
export function publicKeyFromAuthHeader(header: string | undefined): string | undefined {
|
|
61
|
+
if (!header) return undefined;
|
|
62
|
+
// `Sentry sentry_version=7,sentry_client=...,sentry_key=<key>,sentry_secret=...`
|
|
63
|
+
const m = /sentry_key=([^,\s]+)/.exec(header);
|
|
64
|
+
return m ? m[1] : undefined;
|
|
65
|
+
}
|
|
66
|
+
export function publicKeyFromDsn(dsn: string | undefined): string | undefined {
|
|
67
|
+
if (!dsn) return undefined;
|
|
68
|
+
// `https://<publicKey>@host/<projectId>` (or `<publicKey>:<secret>@host/...`)
|
|
69
|
+
const m = /\/\/([^:@/]+)(?::[^@]*)?@/.exec(dsn);
|
|
70
|
+
return m ? m[1] : undefined;
|
|
71
|
+
}
|
|
72
|
+
/** Resolve the ingest public key from header, query string, or envelope-header dsn. */
|
|
73
|
+
export function resolvePublicKey(opts: { authHeader?: string; query?: string; envelopeDsn?: string }): string | undefined {
|
|
74
|
+
const fromHeader = publicKeyFromAuthHeader(opts.authHeader);
|
|
75
|
+
if (fromHeader) return fromHeader;
|
|
76
|
+
if (opts.query) {
|
|
77
|
+
const params = new URLSearchParams(opts.query);
|
|
78
|
+
const k = params.get('sentry_key');
|
|
79
|
+
if (k) return k;
|
|
80
|
+
}
|
|
81
|
+
return publicKeyFromDsn(opts.envelopeDsn);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Project key (DSN) resolution against twin state ──────────────────────────────
|
|
85
|
+
// A project_key resource carries { public, projectId, projectSlug, organizationSlug }.
|
|
86
|
+
// Ingestion is authorized iff a project_key with this public key exists. The twin is
|
|
87
|
+
// seeded with a default key/project so an out-of-the-box DSN works (see ensureDefaultKey).
|
|
88
|
+
export type ResolvedKey = { public: string; projectId: string; projectSlug: string; organizationSlug: string };
|
|
89
|
+
|
|
90
|
+
export function resolveProjectKey(publicKey: string | undefined, root?: string): ResolvedKey | undefined {
|
|
91
|
+
if (!publicKey) return undefined;
|
|
92
|
+
const key = projectResources(SERVICE, root).find((r) => r.type === 'project_key' && r.public === publicKey && !r.deleted);
|
|
93
|
+
if (!key) return undefined;
|
|
94
|
+
return {
|
|
95
|
+
public: String(key.public),
|
|
96
|
+
projectId: String(key.projectId),
|
|
97
|
+
projectSlug: String(key.projectSlug ?? ''),
|
|
98
|
+
organizationSlug: String(key.organizationSlug ?? ''),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Deterministic fingerprint / grouping ─────────────────────────────────────────
|
|
103
|
+
// Real Sentry groups events into issues via a fingerprint. We keep this FAITHFUL-BUT-
|
|
104
|
+
// SIMPLE (real Sentry's full grouping-enhancement/stack-aware algorithm is a filed todo,
|
|
105
|
+
// sentry.ingest.grouping_enhancements). Our rule, in priority order, mirrors Sentry's default-grouping inputs:
|
|
106
|
+
// 1. exception present → `${type}: ${value-with-numbers-normalized}` + top in-app frame
|
|
107
|
+
// 2. message/logentry → the message template (formatted message, numbers normalized)
|
|
108
|
+
// 3. fallback → the culprit/transaction or 'unknown'
|
|
109
|
+
// The fingerprint is hashed to a stable short hex used as the issue id, so the SAME error
|
|
110
|
+
// captured twice ALWAYS lands in the same issue (the grouping guarantee).
|
|
111
|
+
export function normalizeFingerprintText(text: string): string {
|
|
112
|
+
// Strip volatile tokens so "User 42 not found" and "User 99 not found" group together,
|
|
113
|
+
// mirroring Sentry's parameterization of numbers/hex/uuids in default grouping.
|
|
114
|
+
return text
|
|
115
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<uuid>')
|
|
116
|
+
.replace(/0x[0-9a-f]+/gi, '<hex>')
|
|
117
|
+
.replace(/\b\d+\b/g, '<num>')
|
|
118
|
+
.trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function exceptionValues(event: SentryEventPayload): SentryExceptionValue[] {
|
|
122
|
+
const ex = event.exception;
|
|
123
|
+
if (!ex) return [];
|
|
124
|
+
if (Array.isArray(ex)) return ex;
|
|
125
|
+
return Array.isArray(ex.values) ? ex.values : [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function messageText(event: SentryEventPayload): string | undefined {
|
|
129
|
+
const m = event.message;
|
|
130
|
+
if (typeof m === 'string') return m;
|
|
131
|
+
if (m && typeof m === 'object') return m.formatted ?? m.message;
|
|
132
|
+
const le = event.logentry;
|
|
133
|
+
if (le) return le.formatted ?? le.message;
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The human title of an issue derived from an event (Sentry's issue title rules). */
|
|
138
|
+
export function eventTitle(event: SentryEventPayload): string {
|
|
139
|
+
const values = exceptionValues(event);
|
|
140
|
+
const top = values[values.length - 1];
|
|
141
|
+
if (top && (top.type || top.value)) {
|
|
142
|
+
return top.value ? `${top.type ?? 'Error'}: ${top.value}` : String(top.type);
|
|
143
|
+
}
|
|
144
|
+
const msg = messageText(event);
|
|
145
|
+
if (msg) return msg;
|
|
146
|
+
return event.transaction || event.culprit || '<unknown>';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The culprit: Sentry's "where" line — the top in-app frame's function@module, or transaction. */
|
|
150
|
+
export function eventCulprit(event: SentryEventPayload): string {
|
|
151
|
+
if (event.culprit) return String(event.culprit);
|
|
152
|
+
const values = exceptionValues(event);
|
|
153
|
+
const top = values[values.length - 1];
|
|
154
|
+
const frames = top?.stacktrace?.frames ?? [];
|
|
155
|
+
// Sentry uses the last (innermost) in-app frame, else the last frame.
|
|
156
|
+
const inApp = [...frames].reverse().find((f) => f.in_app) ?? frames[frames.length - 1];
|
|
157
|
+
if (inApp) {
|
|
158
|
+
const where = inApp.function ?? inApp.filename ?? inApp.module ?? '?';
|
|
159
|
+
const mod = inApp.module ?? inApp.filename;
|
|
160
|
+
return mod ? `${where}(${mod})` : where;
|
|
161
|
+
}
|
|
162
|
+
return event.transaction || '';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The deterministic grouping key text (pre-hash) for an event. */
|
|
166
|
+
export function fingerprintText(event: SentryEventPayload): string {
|
|
167
|
+
const values = exceptionValues(event);
|
|
168
|
+
const top = values[values.length - 1];
|
|
169
|
+
if (top && (top.type || top.value)) {
|
|
170
|
+
const base = `${top.type ?? 'Error'}: ${normalizeFingerprintText(top.value ?? '')}`;
|
|
171
|
+
return `${base}|${eventCulprit(event)}`;
|
|
172
|
+
}
|
|
173
|
+
const msg = messageText(event);
|
|
174
|
+
if (msg) return `message:${normalizeFingerprintText(msg)}`;
|
|
175
|
+
return `culprit:${event.culprit ?? event.transaction ?? 'unknown'}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** djb2 hash → 16-hex-char stable short id (deterministic, no crypto needed). */
|
|
179
|
+
export function hashFingerprint(text: string): string {
|
|
180
|
+
// 64-bit-ish via two 32-bit accumulators so collisions are vanishingly unlikely.
|
|
181
|
+
let h1 = 5381 >>> 0;
|
|
182
|
+
let h2 = 52711 >>> 0;
|
|
183
|
+
for (let i = 0; i < text.length; i++) {
|
|
184
|
+
const c = text.charCodeAt(i);
|
|
185
|
+
h1 = (((h1 << 5) + h1) ^ c) >>> 0;
|
|
186
|
+
h2 = (((h2 << 5) + h2) + c) >>> 0;
|
|
187
|
+
}
|
|
188
|
+
return (h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0')).slice(0, 16);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The issue id (a stable hex group id) for an event in a given project. */
|
|
192
|
+
export function issueIdFor(projectId: string, event: SentryEventPayload): string {
|
|
193
|
+
return hashFingerprint(`${projectId}|${fingerprintText(event)}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function levelOf(event: SentryEventPayload): string {
|
|
197
|
+
const l = typeof event.level === 'string' ? event.level : 'error';
|
|
198
|
+
return ['fatal', 'error', 'warning', 'info', 'debug'].includes(l) ? l : 'error';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function tagsObject(event: SentryEventPayload): Record<string, string> {
|
|
202
|
+
const t = event.tags;
|
|
203
|
+
if (!t) return {};
|
|
204
|
+
if (Array.isArray(t)) return Object.fromEntries(t.map(([k, v]) => [String(k), String(v)]));
|
|
205
|
+
return Object.fromEntries(Object.entries(t).map(([k, v]) => [k, String(v)]));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function toIso(ts: number | string | undefined, fallback: string): string {
|
|
209
|
+
if (typeof ts === 'number') return new Date(ts * 1000).toISOString();
|
|
210
|
+
if (typeof ts === 'string' && ts) {
|
|
211
|
+
const d = new Date(/^\d+(\.\d+)?$/.test(ts) ? Number(ts) * 1000 : ts);
|
|
212
|
+
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
|
213
|
+
}
|
|
214
|
+
return fallback;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function rows(type: string, root?: string): TwinResource[] {
|
|
218
|
+
return projectResources(SERVICE, root).filter((r) => r.type === type);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let eventCounter = 0;
|
|
222
|
+
function genEventId(provided?: string): string {
|
|
223
|
+
if (provided && /^[0-9a-f]{32}$/i.test(provided.replace(/-/g, ''))) return provided.replace(/-/g, '').toLowerCase();
|
|
224
|
+
// 32-hex deterministic-ish id (mirrors Sentry's UUID-without-dashes event_id shape).
|
|
225
|
+
const seq = (++eventCounter).toString(16).padStart(8, '0');
|
|
226
|
+
return (seq + 'f'.repeat(24)).slice(0, 32);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type IngestOutcome = {
|
|
230
|
+
eventId: string;
|
|
231
|
+
issueId: string;
|
|
232
|
+
isNew: boolean; // true if this created a brand-new issue (→ new-issue alert)
|
|
233
|
+
wasResolved: boolean; // true if the issue was resolved/ignored and this event reopened it (→ regression)
|
|
234
|
+
issue: Record<string, unknown>;
|
|
235
|
+
event: Record<string, unknown>;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Ingest ONE event into the twin: derive its issue group, upsert the issue (count/seen/
|
|
240
|
+
* status), persist the event, and return the outcome (for the caller to fire alerts).
|
|
241
|
+
* Pure local writes via the kernel — no network. `occurredAt` keeps it deterministic.
|
|
242
|
+
*/
|
|
243
|
+
export async function ingestEvent(
|
|
244
|
+
key: ResolvedKey,
|
|
245
|
+
event: SentryEventPayload,
|
|
246
|
+
opts: { root?: string; occurredAt?: string } = {},
|
|
247
|
+
): Promise<IngestOutcome> {
|
|
248
|
+
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
249
|
+
const eventTs = toIso(event.timestamp, occurredAt);
|
|
250
|
+
const eventId = genEventId(event.event_id);
|
|
251
|
+
const issueId = issueIdFor(key.projectId, event);
|
|
252
|
+
const level = levelOf(event);
|
|
253
|
+
const title = eventTitle(event);
|
|
254
|
+
const culprit = eventCulprit(event);
|
|
255
|
+
const tags = tagsObject(event);
|
|
256
|
+
|
|
257
|
+
const existing = rows('issue', opts.root).find((r) => r.id === issueId);
|
|
258
|
+
const isNew = !existing;
|
|
259
|
+
const prevStatus = existing ? String(existing.status ?? 'unresolved') : 'unresolved';
|
|
260
|
+
// A new event on a resolved/ignored issue REOPENS it (Sentry "regression").
|
|
261
|
+
const wasResolved = !isNew && (prevStatus === 'resolved' || prevStatus === 'ignored');
|
|
262
|
+
const prevCount = existing ? Number(existing.count ?? 0) : 0;
|
|
263
|
+
const firstSeen = existing ? String(existing.firstSeen ?? eventTs) : eventTs;
|
|
264
|
+
|
|
265
|
+
const issueFields: Record<string, unknown> = {
|
|
266
|
+
projectId: key.projectId,
|
|
267
|
+
projectSlug: key.projectSlug,
|
|
268
|
+
shortId: `${(key.projectSlug || 'PROJECT').toUpperCase()}-${issueId.slice(0, 4).toUpperCase()}`,
|
|
269
|
+
title,
|
|
270
|
+
culprit,
|
|
271
|
+
level,
|
|
272
|
+
platform: typeof event.platform === 'string' ? event.platform : 'other',
|
|
273
|
+
status: wasResolved ? 'unresolved' : prevStatus,
|
|
274
|
+
count: String(prevCount + 1),
|
|
275
|
+
userCount: 0,
|
|
276
|
+
firstSeen,
|
|
277
|
+
lastSeen: eventTs,
|
|
278
|
+
type: existing && existing.metadataType ? existing.metadataType : (exceptionValues(event).length ? 'error' : 'default'),
|
|
279
|
+
metadata: { type: exceptionValues(event)[exceptionValues(event).length - 1]?.type, value: exceptionValues(event)[exceptionValues(event).length - 1]?.value, title },
|
|
280
|
+
isUnhandled: event.level === 'fatal',
|
|
281
|
+
...(existing?.assignedTo !== undefined ? { assignedTo: existing.assignedTo } : {}),
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const { resource: issueRes } = await applyTwinWrite(SERVICE, {
|
|
285
|
+
operation: isNew ? 'issue.create' : 'issue.observe',
|
|
286
|
+
subjectType: 'issue', subjectId: issueId, fields: issueFields, occurredAt,
|
|
287
|
+
actor: { kind: 'system' },
|
|
288
|
+
}, opts.root);
|
|
289
|
+
|
|
290
|
+
const eventFields: Record<string, unknown> = {
|
|
291
|
+
eventID: eventId,
|
|
292
|
+
groupID: issueId,
|
|
293
|
+
projectID: key.projectId,
|
|
294
|
+
title,
|
|
295
|
+
culprit,
|
|
296
|
+
level,
|
|
297
|
+
platform: issueFields.platform,
|
|
298
|
+
message: messageText(event) ?? '',
|
|
299
|
+
dateCreated: eventTs,
|
|
300
|
+
dateReceived: occurredAt,
|
|
301
|
+
tags: Object.entries(tags).map(([k, v]) => ({ key: k, value: v })),
|
|
302
|
+
environment: typeof event.environment === 'string' ? event.environment : null,
|
|
303
|
+
release: typeof event.release === 'string' ? event.release : null,
|
|
304
|
+
'entries:exception': exceptionValues(event),
|
|
305
|
+
};
|
|
306
|
+
await applyTwinWrite(SERVICE, {
|
|
307
|
+
operation: 'event.observe', subjectType: 'event', subjectId: eventId, fields: eventFields, occurredAt,
|
|
308
|
+
actor: { kind: 'system' },
|
|
309
|
+
}, opts.root);
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
eventId, issueId, isNew, wasResolved,
|
|
313
|
+
issue: { id: issueId, ...issueFields },
|
|
314
|
+
event: { id: eventId, ...eventFields },
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Envelope parsing ─────────────────────────────────────────────────────────────
|
|
319
|
+
// The modern SDK POSTs an "envelope": newline-delimited JSON —
|
|
320
|
+
// {envelope header}\n {item header}\n {item payload}\n {item header}\n {item payload}...
|
|
321
|
+
// We parse the header (for the dsn) and extract every `event` / `transaction` item.
|
|
322
|
+
// `attachment`/`session`/`client_report` items are accepted and ignored (faithful — Sentry
|
|
323
|
+
// 200s the whole envelope regardless), so the SDK's transport sees success.
|
|
324
|
+
export type ParsedEnvelope = { header: Record<string, unknown>; items: Array<{ header: Record<string, unknown>; payload: unknown }> };
|
|
325
|
+
|
|
326
|
+
export function parseEnvelope(body: string): ParsedEnvelope {
|
|
327
|
+
const lines = body.split('\n');
|
|
328
|
+
let idx = 0;
|
|
329
|
+
const header = safeJson(lines[idx++] ?? '{}') as Record<string, unknown>;
|
|
330
|
+
const items: Array<{ header: Record<string, unknown>; payload: unknown }> = [];
|
|
331
|
+
while (idx < lines.length) {
|
|
332
|
+
const headerLine = lines[idx++];
|
|
333
|
+
if (headerLine === undefined || headerLine === '') continue;
|
|
334
|
+
const itemHeader = safeJson(headerLine) as Record<string, unknown>;
|
|
335
|
+
const payloadLine = lines[idx++] ?? '';
|
|
336
|
+
items.push({ header: itemHeader, payload: safeJson(payloadLine) });
|
|
337
|
+
}
|
|
338
|
+
return { header, items };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function safeJson(s: string): unknown {
|
|
342
|
+
try { return JSON.parse(s); } catch { return {}; }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Extract the event payloads (type 'event') from a parsed envelope. */
|
|
346
|
+
export function envelopeEvents(env: ParsedEnvelope): SentryEventPayload[] {
|
|
347
|
+
return env.items
|
|
348
|
+
.filter((it) => it.header.type === 'event')
|
|
349
|
+
.map((it) => it.payload as SentryEventPayload);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Attachment item metadata from an envelope (type 'attachment'). The binary payload bytes
|
|
353
|
+
* are not stored (there is no blob store yet); the metadata (name, size, type, content_type)
|
|
354
|
+
* IS captured so the attachments listing endpoint can serve it faithfully. */
|
|
355
|
+
export type EnvelopeAttachment = { name: string; size: number; contentType: string; attachmentType: string };
|
|
356
|
+
export function envelopeAttachments(env: ParsedEnvelope): EnvelopeAttachment[] {
|
|
357
|
+
return env.items
|
|
358
|
+
.filter((it) => it.header.type === 'attachment')
|
|
359
|
+
.map((it) => {
|
|
360
|
+
const h = it.header;
|
|
361
|
+
const payload = it.payload;
|
|
362
|
+
const declared = typeof h.length === 'number' ? h.length : undefined;
|
|
363
|
+
const size = declared ?? (typeof payload === 'string' ? payload.length : JSON.stringify(payload ?? '').length);
|
|
364
|
+
return {
|
|
365
|
+
name: String(h.filename ?? 'attachment'),
|
|
366
|
+
size,
|
|
367
|
+
contentType: String(h.content_type ?? 'application/octet-stream'),
|
|
368
|
+
attachmentType: String(h.attachment_type ?? 'event.attachment'),
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A session item from an envelope (type 'session'): the SDK's release-health heartbeat. */
|
|
374
|
+
export type EnvelopeSession = { sid: string; status: string; release?: string; environment?: string; errors: number };
|
|
375
|
+
export function envelopeSessions(env: ParsedEnvelope): EnvelopeSession[] {
|
|
376
|
+
return env.items
|
|
377
|
+
.filter((it) => it.header.type === 'session')
|
|
378
|
+
.map((it) => {
|
|
379
|
+
const p = (it.payload && typeof it.payload === 'object') ? it.payload as Record<string, unknown> : {};
|
|
380
|
+
const attrs = (p.attrs && typeof p.attrs === 'object') ? p.attrs as Record<string, unknown> : {};
|
|
381
|
+
return {
|
|
382
|
+
sid: String(p.sid ?? ''),
|
|
383
|
+
status: String(p.status ?? 'ok'),
|
|
384
|
+
release: typeof attrs.release === 'string' ? attrs.release : undefined,
|
|
385
|
+
environment: typeof attrs.environment === 'string' ? attrs.environment : undefined,
|
|
386
|
+
errors: typeof p.errors === 'number' ? p.errors : 0,
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Persist release-health sessions as `session` resources (upsert by sid: a session is a
|
|
392
|
+
* lifecycle, the final status wins). This is fully reproducible offline — crash-free counts
|
|
393
|
+
* are exact tallies over the persisted sessions (NOT a fabricated metrics engine). */
|
|
394
|
+
export async function persistSessions(
|
|
395
|
+
projectId: string,
|
|
396
|
+
sessions: EnvelopeSession[],
|
|
397
|
+
opts: { root?: string; occurredAt?: string } = {},
|
|
398
|
+
): Promise<void> {
|
|
399
|
+
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
400
|
+
for (const s of sessions) {
|
|
401
|
+
if (!s.sid) continue;
|
|
402
|
+
await applyTwinWrite(SERVICE, {
|
|
403
|
+
operation: 'session.observe', subjectType: 'session', subjectId: s.sid,
|
|
404
|
+
fields: {
|
|
405
|
+
projectID: projectId, status: s.status, release: s.release ?? null,
|
|
406
|
+
environment: s.environment ?? null, errors: s.errors, lastUpdate: occurredAt,
|
|
407
|
+
},
|
|
408
|
+
occurredAt, actor: { kind: 'system' },
|
|
409
|
+
}, opts.root);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Persist an event's attachments (metadata only) as `event_attachment` resources. */
|
|
414
|
+
export async function persistEventAttachments(
|
|
415
|
+
eventId: string,
|
|
416
|
+
projectId: string,
|
|
417
|
+
attachments: EnvelopeAttachment[],
|
|
418
|
+
opts: { root?: string; occurredAt?: string } = {},
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
421
|
+
let i = rows('event_attachment', opts.root).length;
|
|
422
|
+
for (const a of attachments) {
|
|
423
|
+
i += 1;
|
|
424
|
+
await applyTwinWrite(SERVICE, {
|
|
425
|
+
operation: 'event_attachment.create', subjectType: 'event_attachment', subjectId: `att_${i}`,
|
|
426
|
+
fields: {
|
|
427
|
+
eventId, projectID: projectId, name: a.name, size: a.size,
|
|
428
|
+
mimetype: a.contentType, type: a.attachmentType,
|
|
429
|
+
sha1: hashFingerprint(`${eventId}|${a.name}|${a.size}`),
|
|
430
|
+
dateCreated: occurredAt,
|
|
431
|
+
headers: { 'Content-Type': a.contentType },
|
|
432
|
+
},
|
|
433
|
+
occurredAt, actor: { kind: 'system' },
|
|
434
|
+
}, opts.root);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Default project + key seeding ────────────────────────────────────────────────
|
|
439
|
+
// So an out-of-the-box DSN works against a fresh twin, ensure a default org/project/key
|
|
440
|
+
// exists. Idempotent: only writes when absent. The default public key is deterministic.
|
|
441
|
+
export const DEFAULT_ORG = 'twin-org';
|
|
442
|
+
export const DEFAULT_PROJECT = 'twin-project';
|
|
443
|
+
export const DEFAULT_PROJECT_ID = '1';
|
|
444
|
+
export const DEFAULT_PUBLIC_KEY = 'twinpublickey00000000000000000000';
|
|
445
|
+
|
|
446
|
+
export async function ensureDefaultProject(root?: string, occurredAt?: string): Promise<void> {
|
|
447
|
+
const ts = occurredAt ?? '1970-01-01T00:00:00.000Z';
|
|
448
|
+
const haveOrg = rows('organization', root).some((r) => r.slug === DEFAULT_ORG);
|
|
449
|
+
if (!haveOrg) {
|
|
450
|
+
await applyTwinWrite(SERVICE, { operation: 'organization.create', subjectType: 'organization', subjectId: 'org_1', fields: { slug: DEFAULT_ORG, name: 'Twin Org', dateCreated: ts }, occurredAt: ts, actor: { kind: 'system' } }, root);
|
|
451
|
+
}
|
|
452
|
+
const haveProj = rows('project', root).some((r) => r.id === DEFAULT_PROJECT_ID);
|
|
453
|
+
if (!haveProj) {
|
|
454
|
+
await applyTwinWrite(SERVICE, { operation: 'project.create', subjectType: 'project', subjectId: DEFAULT_PROJECT_ID, fields: { slug: DEFAULT_PROJECT, name: 'Twin Project', platform: 'node', organizationSlug: DEFAULT_ORG, dateCreated: ts }, occurredAt: ts, actor: { kind: 'system' } }, root);
|
|
455
|
+
}
|
|
456
|
+
const haveKey = rows('project_key', root).some((r) => r.public === DEFAULT_PUBLIC_KEY);
|
|
457
|
+
if (!haveKey) {
|
|
458
|
+
await applyTwinWrite(SERVICE, {
|
|
459
|
+
operation: 'project_key.create', subjectType: 'project_key', subjectId: 'key_1',
|
|
460
|
+
fields: { public: DEFAULT_PUBLIC_KEY, secret: 'twinsecret00000000000000000000000', projectId: DEFAULT_PROJECT_ID, projectSlug: DEFAULT_PROJECT, organizationSlug: DEFAULT_ORG, name: 'Default', dateCreated: ts, isActive: true },
|
|
461
|
+
occurredAt: ts, actor: { kind: 'system' },
|
|
462
|
+
}, root);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** The default DSN an SDK can use against a twin server at `origin` (e.g. http://127.0.0.1:PORT). */
|
|
467
|
+
export function defaultDsn(origin: string): string {
|
|
468
|
+
const host = origin.replace(/^https?:\/\//, '');
|
|
469
|
+
const protocol = origin.startsWith('https') ? 'https' : 'http';
|
|
470
|
+
return `${protocol}://${DEFAULT_PUBLIC_KEY}@${host}/${DEFAULT_PROJECT_ID}`;
|
|
471
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Sentry MIRROR UI — a Sentry-dashboard-like view served as a React/TSX app (Bun-bundled,
|
|
2
|
+
// the repo convention). It renders by consuming the twin's OWN Web API on the same origin
|
|
3
|
+
// (/api/0/...) — the same endpoints a real client uses. API↔UI parity: every screen is
|
|
4
|
+
// backed by real twin state, never a hand-made fixture.
|
|
5
|
+
//
|
|
6
|
+
// PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the
|
|
7
|
+
// pack's OWN fetch adapter (createSentryTwinFetch) as its API backend and reads every byte
|
|
8
|
+
// of state back over the wire, exactly as a browser does.
|
|
9
|
+
//
|
|
10
|
+
// The pure format/level helpers live here so they can be unit-tested AND shared by the
|
|
11
|
+
// React client (Bun tree-shakes the server-only exports out of the browser bundle).
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { bundleClient, fileResponse } from '@volter/world-core';
|
|
14
|
+
import { serveHttp } from '@volter/world-core';
|
|
15
|
+
import { createSentryTwinFetch } from './sentry-server.ts';
|
|
16
|
+
import { DEFAULT_ORG, DEFAULT_PROJECT } from './sentry-ingest.ts';
|
|
17
|
+
|
|
18
|
+
const CLIENT_ENTRY = () => new URL('../client/sentry-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
|
|
19
|
+
const CLIENT_CSS = () => new URL('../client/sentry-mirror.css', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Pure, dependency-free render/format helpers (plain data in → plain data out).
|
|
23
|
+
// Keep them free of any @volter/world-core / Bun / handler usage.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export type IssueRow = Record<string, any>;
|
|
26
|
+
|
|
27
|
+
/** Map a Sentry level to a UI tone class. */
|
|
28
|
+
export function levelTone(level: unknown): 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'muted' {
|
|
29
|
+
const l = String(level ?? '').toLowerCase();
|
|
30
|
+
if (l === 'fatal' || l === 'error' || l === 'warning' || l === 'info' || l === 'debug') return l;
|
|
31
|
+
return 'muted';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Map an issue status to a UI tone class. */
|
|
35
|
+
export function statusTone(status: unknown): 'unresolved' | 'resolved' | 'ignored' | 'muted' {
|
|
36
|
+
const s = String(status ?? '').toLowerCase();
|
|
37
|
+
if (s === 'resolved') return 'resolved';
|
|
38
|
+
if (s === 'ignored' || s === 'muted') return 'ignored';
|
|
39
|
+
if (s === 'unresolved') return 'unresolved';
|
|
40
|
+
return 'muted';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** "12.3k", "1.2M", "999" — Sentry's compact event-count formatting. */
|
|
44
|
+
export function formatCount(value: unknown): string {
|
|
45
|
+
const n = Number(value);
|
|
46
|
+
if (!Number.isFinite(n)) return '0';
|
|
47
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
|
|
48
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`;
|
|
49
|
+
return String(Math.trunc(n));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Relative "time ago" for a lastSeen/firstSeen ISO string (deterministic given `now`). */
|
|
53
|
+
export function timeAgo(iso: unknown, now = Date.now()): string {
|
|
54
|
+
if (typeof iso !== 'string' || !iso) return '—';
|
|
55
|
+
const t = Date.parse(iso);
|
|
56
|
+
if (Number.isNaN(t)) return '—';
|
|
57
|
+
const s = Math.max(0, Math.floor((now - t) / 1000));
|
|
58
|
+
if (s < 60) return `${s}s ago`;
|
|
59
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
60
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
61
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Extract the latest event's exception stacktrace frames for the detail view. */
|
|
65
|
+
export type StackFrame = { filename?: string; function?: string; lineno?: number; module?: string };
|
|
66
|
+
export function latestStacktrace(event: unknown): StackFrame[] {
|
|
67
|
+
if (!event || typeof event !== 'object') return [];
|
|
68
|
+
const entries = (event as Record<string, unknown>).entries;
|
|
69
|
+
if (!Array.isArray(entries)) return [];
|
|
70
|
+
const ex = entries.find((e) => e && typeof e === 'object' && (e as Record<string, unknown>).type === 'exception');
|
|
71
|
+
const values = ex && (ex as any).data?.values;
|
|
72
|
+
if (!Array.isArray(values)) return [];
|
|
73
|
+
const top = values[values.length - 1];
|
|
74
|
+
const frames = top?.stacktrace?.frames;
|
|
75
|
+
return Array.isArray(frames) ? frames : [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The event's tags as a flat [key,value] list for rendering. */
|
|
79
|
+
export function eventTags(event: unknown): Array<{ key: string; value: string }> {
|
|
80
|
+
if (!event || typeof event !== 'object') return [];
|
|
81
|
+
const tags = (event as Record<string, unknown>).tags;
|
|
82
|
+
if (!Array.isArray(tags)) return [];
|
|
83
|
+
return tags
|
|
84
|
+
.filter((t) => t && typeof t === 'object')
|
|
85
|
+
.map((t) => ({ key: String((t as any).key), value: String((t as any).value) }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Server-only: bundle the React client + serve it alongside the twin Web API.
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
let clientBundle: Promise<string> | null = null;
|
|
92
|
+
export function buildSentryMirrorClient(): Promise<string> {
|
|
93
|
+
if (!clientBundle) {
|
|
94
|
+
clientBundle = bundleClient(CLIENT_ENTRY())
|
|
95
|
+
.catch((error) => { clientBundle = null; throw error; });
|
|
96
|
+
}
|
|
97
|
+
return clientBundle;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const APP_SHELL = `<!doctype html>
|
|
101
|
+
<html lang="en">
|
|
102
|
+
<head>
|
|
103
|
+
<meta charset="utf-8" />
|
|
104
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
105
|
+
<base href="/"><title>Sentry (twin)</title>
|
|
106
|
+
<link rel="stylesheet" href="assets/styles.css" />
|
|
107
|
+
</head>
|
|
108
|
+
<body>
|
|
109
|
+
<div id="root"></div>
|
|
110
|
+
<script type="module" src="assets/app.js"></script>
|
|
111
|
+
</body>
|
|
112
|
+
</html>`;
|
|
113
|
+
|
|
114
|
+
/** Serve the Sentry dashboard mirror UI (React app) + its backing Web API. */
|
|
115
|
+
export async function createSentryMirrorServer(options: { root?: string; port?: number } = {}): Promise<{ port: number; stop: () => void }> {
|
|
116
|
+
const twin = createSentryTwinFetch(options);
|
|
117
|
+
const server = await serveHttp({
|
|
118
|
+
// LOOPBACK-SPECIFIC bind (2026-08-20, the roving ui-verify flake): with the default
|
|
119
|
+
// wildcard hostname, `port: 0` can be handed a port some long-running app already LISTENS
|
|
120
|
+
// on at 127.0.0.1 (SO_REUSEADDR allows the overlapping non-identical bind), and the more
|
|
121
|
+
// specific loopback listener then shadows this server for every 127.0.0.1 fetch — the
|
|
122
|
+
// verify talks to a STRANGER (captured: a desktop app's asset server answering 404s on the
|
|
123
|
+
// mirror's port). Binding 127.0.0.1 makes the kernel allocate a port that is actually free
|
|
124
|
+
// on loopback, so the verify's fetches deterministically reach THIS server.
|
|
125
|
+
hostname: '127.0.0.1',
|
|
126
|
+
port: options.port ?? 0,
|
|
127
|
+
idleTimeout: 60,
|
|
128
|
+
async fetch(request) {
|
|
129
|
+
const url = new URL(request.url);
|
|
130
|
+
if (request.method === 'GET' && url.pathname === '/assets/app.js') {
|
|
131
|
+
try { return new Response(await buildSentryMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } }); }
|
|
132
|
+
catch (error) { return new Response(String(error), { status: 500 }); }
|
|
133
|
+
}
|
|
134
|
+
if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
|
|
135
|
+
return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
136
|
+
}
|
|
137
|
+
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
|
|
138
|
+
return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
139
|
+
}
|
|
140
|
+
// everything else → the twin's OWN FETCH ADAPTER (composition, R2): the Web API the React
|
|
141
|
+
// client calls (/api/0/...), the SDK's ingest path (/api/:projectId/envelope/) and the
|
|
142
|
+
// uniform doors (GET /twin) all come from the SAME closure `createSentryTwinServer` serves,
|
|
143
|
+
// so the mirror port cannot drift from the API port — one serving code path, one truth.
|
|
144
|
+
return twin(request);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The app-shell HTML (pure, for tests). */
|
|
151
|
+
export function sentryMirrorHtml(): string {
|
|
152
|
+
return APP_SHELL;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
156
|
+
export function sentryMirrorStyles(): Promise<string> {
|
|
157
|
+
return readFile(CLIENT_CSS(), 'utf8');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The default org/project the mirror lists issues for (matches the seeded defaults). */
|
|
161
|
+
export const MIRROR_DEFAULT_ORG = DEFAULT_ORG;
|
|
162
|
+
export const MIRROR_DEFAULT_PROJECT = DEFAULT_PROJECT;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// A MINIATURE OF THE HEAD, for this pack's own claims and suites (protocol 2). Not on the serve path.
|
|
2
|
+
import { confirmAction, deployableEntries, worldNow } from '@volter/world-core';
|
|
3
|
+
import { pushSentryAction, type SentryExecute } from './sentry-connector.ts';
|
|
4
|
+
|
|
5
|
+
export async function performPending(execute: SentryExecute, opts: { root?: string; occurredAt: string }): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string> }> {
|
|
6
|
+
const confirmed: string[] = [];
|
|
7
|
+
const externalIds: Record<string, string> = {};
|
|
8
|
+
for (const entry of deployableEntries('sentry', opts.root)) {
|
|
9
|
+
if (entry.operation !== 'issue.update') continue; // only triage crosses
|
|
10
|
+
const { externalId } = await pushSentryAction(execute, { operation: entry.operation, subject: entry.subject, fields: entry.fields ?? {} });
|
|
11
|
+
confirmAction({
|
|
12
|
+
service: 'sentry', actionId: entry.id, subject: entry.subject, fields: entry.fields ?? {},
|
|
13
|
+
occurredAt: opts.occurredAt ?? worldNow(), vendorSubjectId: externalId, receipt: { status: 'deployed' },
|
|
14
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
15
|
+
});
|
|
16
|
+
confirmed.push(entry.id); externalIds[entry.id] = externalId;
|
|
17
|
+
}
|
|
18
|
+
return { pushed: confirmed.length, confirmed, externalIds };
|
|
19
|
+
}
|