@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.
Files changed (50) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +142 -0
  3. package/client/sentry-mirror.css +99 -0
  4. package/client/sentry-mirror.tsx +352 -0
  5. package/dist/client/sentry-mirror.bundle.js +321 -0
  6. package/dist/client/sentry-mirror.css +99 -0
  7. package/dist/client/sentry-mirror.d.ts +17 -0
  8. package/dist/client/sentry-mirror.js +156 -0
  9. package/dist/client/sentry-mirror.tsx +352 -0
  10. package/dist/src/cli.d.ts +2 -0
  11. package/dist/src/cli.js +36 -0
  12. package/dist/src/index.d.ts +15 -0
  13. package/dist/src/index.js +75 -0
  14. package/dist/src/sentry-budget.d.ts +50 -0
  15. package/dist/src/sentry-budget.js +145 -0
  16. package/dist/src/sentry-capabilities.d.ts +3 -0
  17. package/dist/src/sentry-capabilities.js +1180 -0
  18. package/dist/src/sentry-conformance.d.ts +22 -0
  19. package/dist/src/sentry-conformance.js +97 -0
  20. package/dist/src/sentry-connector.d.ts +77 -0
  21. package/dist/src/sentry-connector.js +226 -0
  22. package/dist/src/sentry-events.d.ts +54 -0
  23. package/dist/src/sentry-events.js +131 -0
  24. package/dist/src/sentry-ingest.d.ts +137 -0
  25. package/dist/src/sentry-ingest.js +387 -0
  26. package/dist/src/sentry-mirror-ui.d.ts +38 -0
  27. package/dist/src/sentry-mirror-ui.js +162 -0
  28. package/dist/src/sentry-perform-harness.d.ts +9 -0
  29. package/dist/src/sentry-perform-harness.js +20 -0
  30. package/dist/src/sentry-server.d.ts +14 -0
  31. package/dist/src/sentry-server.js +27 -0
  32. package/dist/src/sentry-twin.d.ts +17 -0
  33. package/dist/src/sentry-twin.js +1739 -0
  34. package/dist/test-fixtures/sentry-openapi-operations.SOURCE.md +24 -0
  35. package/dist/test-fixtures/sentry-openapi-operations.json +1837 -0
  36. package/package.json +75 -0
  37. package/src/cli.ts +34 -0
  38. package/src/index.ts +145 -0
  39. package/src/sentry-budget.ts +171 -0
  40. package/src/sentry-capabilities.ts +1222 -0
  41. package/src/sentry-conformance.ts +110 -0
  42. package/src/sentry-connector.ts +260 -0
  43. package/src/sentry-events.ts +171 -0
  44. package/src/sentry-ingest.ts +471 -0
  45. package/src/sentry-mirror-ui.ts +162 -0
  46. package/src/sentry-perform-harness.ts +19 -0
  47. package/src/sentry-server.ts +35 -0
  48. package/src/sentry-twin.ts +1615 -0
  49. package/test-fixtures/sentry-openapi-operations.SOURCE.md +24 -0
  50. package/test-fixtures/sentry-openapi-operations.json +1837 -0
@@ -0,0 +1,137 @@
1
+ export type SentryEventPayload = {
2
+ event_id?: string;
3
+ level?: string;
4
+ platform?: string;
5
+ message?: string | {
6
+ message?: string;
7
+ formatted?: string;
8
+ };
9
+ logentry?: {
10
+ message?: string;
11
+ formatted?: string;
12
+ };
13
+ transaction?: string;
14
+ culprit?: string;
15
+ environment?: string;
16
+ release?: string;
17
+ server_name?: string;
18
+ timestamp?: number | string;
19
+ tags?: Record<string, unknown> | Array<[string, string]>;
20
+ exception?: {
21
+ values?: SentryExceptionValue[];
22
+ } | SentryExceptionValue[];
23
+ [k: string]: unknown;
24
+ };
25
+ export type SentryExceptionValue = {
26
+ type?: string;
27
+ value?: string;
28
+ module?: string;
29
+ stacktrace?: {
30
+ frames?: SentryFrame[];
31
+ };
32
+ };
33
+ export type SentryFrame = {
34
+ filename?: string;
35
+ function?: string;
36
+ module?: string;
37
+ lineno?: number;
38
+ colno?: number;
39
+ in_app?: boolean;
40
+ abs_path?: string;
41
+ [k: string]: unknown;
42
+ };
43
+ export type IngestResult = {
44
+ status: number;
45
+ body: unknown;
46
+ };
47
+ export declare function publicKeyFromAuthHeader(header: string | undefined): string | undefined;
48
+ export declare function publicKeyFromDsn(dsn: string | undefined): string | undefined;
49
+ /** Resolve the ingest public key from header, query string, or envelope-header dsn. */
50
+ export declare function resolvePublicKey(opts: {
51
+ authHeader?: string;
52
+ query?: string;
53
+ envelopeDsn?: string;
54
+ }): string | undefined;
55
+ export type ResolvedKey = {
56
+ public: string;
57
+ projectId: string;
58
+ projectSlug: string;
59
+ organizationSlug: string;
60
+ };
61
+ export declare function resolveProjectKey(publicKey: string | undefined, root?: string): ResolvedKey | undefined;
62
+ export declare function normalizeFingerprintText(text: string): string;
63
+ /** The human title of an issue derived from an event (Sentry's issue title rules). */
64
+ export declare function eventTitle(event: SentryEventPayload): string;
65
+ /** The culprit: Sentry's "where" line — the top in-app frame's function@module, or transaction. */
66
+ export declare function eventCulprit(event: SentryEventPayload): string;
67
+ /** The deterministic grouping key text (pre-hash) for an event. */
68
+ export declare function fingerprintText(event: SentryEventPayload): string;
69
+ /** djb2 hash → 16-hex-char stable short id (deterministic, no crypto needed). */
70
+ export declare function hashFingerprint(text: string): string;
71
+ /** The issue id (a stable hex group id) for an event in a given project. */
72
+ export declare function issueIdFor(projectId: string, event: SentryEventPayload): string;
73
+ export type IngestOutcome = {
74
+ eventId: string;
75
+ issueId: string;
76
+ isNew: boolean;
77
+ wasResolved: boolean;
78
+ issue: Record<string, unknown>;
79
+ event: Record<string, unknown>;
80
+ };
81
+ /**
82
+ * Ingest ONE event into the twin: derive its issue group, upsert the issue (count/seen/
83
+ * status), persist the event, and return the outcome (for the caller to fire alerts).
84
+ * Pure local writes via the kernel — no network. `occurredAt` keeps it deterministic.
85
+ */
86
+ export declare function ingestEvent(key: ResolvedKey, event: SentryEventPayload, opts?: {
87
+ root?: string;
88
+ occurredAt?: string;
89
+ }): Promise<IngestOutcome>;
90
+ export type ParsedEnvelope = {
91
+ header: Record<string, unknown>;
92
+ items: Array<{
93
+ header: Record<string, unknown>;
94
+ payload: unknown;
95
+ }>;
96
+ };
97
+ export declare function parseEnvelope(body: string): ParsedEnvelope;
98
+ /** Extract the event payloads (type 'event') from a parsed envelope. */
99
+ export declare function envelopeEvents(env: ParsedEnvelope): SentryEventPayload[];
100
+ /** Attachment item metadata from an envelope (type 'attachment'). The binary payload bytes
101
+ * are not stored (there is no blob store yet); the metadata (name, size, type, content_type)
102
+ * IS captured so the attachments listing endpoint can serve it faithfully. */
103
+ export type EnvelopeAttachment = {
104
+ name: string;
105
+ size: number;
106
+ contentType: string;
107
+ attachmentType: string;
108
+ };
109
+ export declare function envelopeAttachments(env: ParsedEnvelope): EnvelopeAttachment[];
110
+ /** A session item from an envelope (type 'session'): the SDK's release-health heartbeat. */
111
+ export type EnvelopeSession = {
112
+ sid: string;
113
+ status: string;
114
+ release?: string;
115
+ environment?: string;
116
+ errors: number;
117
+ };
118
+ export declare function envelopeSessions(env: ParsedEnvelope): EnvelopeSession[];
119
+ /** Persist release-health sessions as `session` resources (upsert by sid: a session is a
120
+ * lifecycle, the final status wins). This is fully reproducible offline — crash-free counts
121
+ * are exact tallies over the persisted sessions (NOT a fabricated metrics engine). */
122
+ export declare function persistSessions(projectId: string, sessions: EnvelopeSession[], opts?: {
123
+ root?: string;
124
+ occurredAt?: string;
125
+ }): Promise<void>;
126
+ /** Persist an event's attachments (metadata only) as `event_attachment` resources. */
127
+ export declare function persistEventAttachments(eventId: string, projectId: string, attachments: EnvelopeAttachment[], opts?: {
128
+ root?: string;
129
+ occurredAt?: string;
130
+ }): Promise<void>;
131
+ export declare const DEFAULT_ORG = "twin-org";
132
+ export declare const DEFAULT_PROJECT = "twin-project";
133
+ export declare const DEFAULT_PROJECT_ID = "1";
134
+ export declare const DEFAULT_PUBLIC_KEY = "twinpublickey00000000000000000000";
135
+ export declare function ensureDefaultProject(root?: string, occurredAt?: string): Promise<void>;
136
+ /** The default DSN an SDK can use against a twin server at `origin` (e.g. http://127.0.0.1:PORT). */
137
+ export declare function defaultDsn(origin: string): string;
@@ -0,0 +1,387 @@
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
+ const SERVICE = 'sentry';
19
+ // ── DSN public key extraction (auth) ─────────────────────────────────────────────
20
+ // The SDK sends the public key in one of three places. We accept all three, mirroring
21
+ // real Sentry's auth resolution order: header → query → envelope-header dsn.
22
+ export function publicKeyFromAuthHeader(header) {
23
+ if (!header)
24
+ return undefined;
25
+ // `Sentry sentry_version=7,sentry_client=...,sentry_key=<key>,sentry_secret=...`
26
+ const m = /sentry_key=([^,\s]+)/.exec(header);
27
+ return m ? m[1] : undefined;
28
+ }
29
+ export function publicKeyFromDsn(dsn) {
30
+ if (!dsn)
31
+ return undefined;
32
+ // `https://<publicKey>@host/<projectId>` (or `<publicKey>:<secret>@host/...`)
33
+ const m = /\/\/([^:@/]+)(?::[^@]*)?@/.exec(dsn);
34
+ return m ? m[1] : undefined;
35
+ }
36
+ /** Resolve the ingest public key from header, query string, or envelope-header dsn. */
37
+ export function resolvePublicKey(opts) {
38
+ const fromHeader = publicKeyFromAuthHeader(opts.authHeader);
39
+ if (fromHeader)
40
+ return fromHeader;
41
+ if (opts.query) {
42
+ const params = new URLSearchParams(opts.query);
43
+ const k = params.get('sentry_key');
44
+ if (k)
45
+ return k;
46
+ }
47
+ return publicKeyFromDsn(opts.envelopeDsn);
48
+ }
49
+ export function resolveProjectKey(publicKey, root) {
50
+ if (!publicKey)
51
+ return undefined;
52
+ const key = projectResources(SERVICE, root).find((r) => r.type === 'project_key' && r.public === publicKey && !r.deleted);
53
+ if (!key)
54
+ return undefined;
55
+ return {
56
+ public: String(key.public),
57
+ projectId: String(key.projectId),
58
+ projectSlug: String(key.projectSlug ?? ''),
59
+ organizationSlug: String(key.organizationSlug ?? ''),
60
+ };
61
+ }
62
+ // ── Deterministic fingerprint / grouping ─────────────────────────────────────────
63
+ // Real Sentry groups events into issues via a fingerprint. We keep this FAITHFUL-BUT-
64
+ // SIMPLE (real Sentry's full grouping-enhancement/stack-aware algorithm is a filed todo,
65
+ // sentry.ingest.grouping_enhancements). Our rule, in priority order, mirrors Sentry's default-grouping inputs:
66
+ // 1. exception present → `${type}: ${value-with-numbers-normalized}` + top in-app frame
67
+ // 2. message/logentry → the message template (formatted message, numbers normalized)
68
+ // 3. fallback → the culprit/transaction or 'unknown'
69
+ // The fingerprint is hashed to a stable short hex used as the issue id, so the SAME error
70
+ // captured twice ALWAYS lands in the same issue (the grouping guarantee).
71
+ export function normalizeFingerprintText(text) {
72
+ // Strip volatile tokens so "User 42 not found" and "User 99 not found" group together,
73
+ // mirroring Sentry's parameterization of numbers/hex/uuids in default grouping.
74
+ return text
75
+ .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>')
76
+ .replace(/0x[0-9a-f]+/gi, '<hex>')
77
+ .replace(/\b\d+\b/g, '<num>')
78
+ .trim();
79
+ }
80
+ function exceptionValues(event) {
81
+ const ex = event.exception;
82
+ if (!ex)
83
+ return [];
84
+ if (Array.isArray(ex))
85
+ return ex;
86
+ return Array.isArray(ex.values) ? ex.values : [];
87
+ }
88
+ function messageText(event) {
89
+ const m = event.message;
90
+ if (typeof m === 'string')
91
+ return m;
92
+ if (m && typeof m === 'object')
93
+ return m.formatted ?? m.message;
94
+ const le = event.logentry;
95
+ if (le)
96
+ return le.formatted ?? le.message;
97
+ return undefined;
98
+ }
99
+ /** The human title of an issue derived from an event (Sentry's issue title rules). */
100
+ export function eventTitle(event) {
101
+ const values = exceptionValues(event);
102
+ const top = values[values.length - 1];
103
+ if (top && (top.type || top.value)) {
104
+ return top.value ? `${top.type ?? 'Error'}: ${top.value}` : String(top.type);
105
+ }
106
+ const msg = messageText(event);
107
+ if (msg)
108
+ return msg;
109
+ return event.transaction || event.culprit || '<unknown>';
110
+ }
111
+ /** The culprit: Sentry's "where" line — the top in-app frame's function@module, or transaction. */
112
+ export function eventCulprit(event) {
113
+ if (event.culprit)
114
+ return String(event.culprit);
115
+ const values = exceptionValues(event);
116
+ const top = values[values.length - 1];
117
+ const frames = top?.stacktrace?.frames ?? [];
118
+ // Sentry uses the last (innermost) in-app frame, else the last frame.
119
+ const inApp = [...frames].reverse().find((f) => f.in_app) ?? frames[frames.length - 1];
120
+ if (inApp) {
121
+ const where = inApp.function ?? inApp.filename ?? inApp.module ?? '?';
122
+ const mod = inApp.module ?? inApp.filename;
123
+ return mod ? `${where}(${mod})` : where;
124
+ }
125
+ return event.transaction || '';
126
+ }
127
+ /** The deterministic grouping key text (pre-hash) for an event. */
128
+ export function fingerprintText(event) {
129
+ const values = exceptionValues(event);
130
+ const top = values[values.length - 1];
131
+ if (top && (top.type || top.value)) {
132
+ const base = `${top.type ?? 'Error'}: ${normalizeFingerprintText(top.value ?? '')}`;
133
+ return `${base}|${eventCulprit(event)}`;
134
+ }
135
+ const msg = messageText(event);
136
+ if (msg)
137
+ return `message:${normalizeFingerprintText(msg)}`;
138
+ return `culprit:${event.culprit ?? event.transaction ?? 'unknown'}`;
139
+ }
140
+ /** djb2 hash → 16-hex-char stable short id (deterministic, no crypto needed). */
141
+ export function hashFingerprint(text) {
142
+ // 64-bit-ish via two 32-bit accumulators so collisions are vanishingly unlikely.
143
+ let h1 = 5381 >>> 0;
144
+ let h2 = 52711 >>> 0;
145
+ for (let i = 0; i < text.length; i++) {
146
+ const c = text.charCodeAt(i);
147
+ h1 = (((h1 << 5) + h1) ^ c) >>> 0;
148
+ h2 = (((h2 << 5) + h2) + c) >>> 0;
149
+ }
150
+ return (h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0')).slice(0, 16);
151
+ }
152
+ /** The issue id (a stable hex group id) for an event in a given project. */
153
+ export function issueIdFor(projectId, event) {
154
+ return hashFingerprint(`${projectId}|${fingerprintText(event)}`);
155
+ }
156
+ function levelOf(event) {
157
+ const l = typeof event.level === 'string' ? event.level : 'error';
158
+ return ['fatal', 'error', 'warning', 'info', 'debug'].includes(l) ? l : 'error';
159
+ }
160
+ function tagsObject(event) {
161
+ const t = event.tags;
162
+ if (!t)
163
+ return {};
164
+ if (Array.isArray(t))
165
+ return Object.fromEntries(t.map(([k, v]) => [String(k), String(v)]));
166
+ return Object.fromEntries(Object.entries(t).map(([k, v]) => [k, String(v)]));
167
+ }
168
+ function toIso(ts, fallback) {
169
+ if (typeof ts === 'number')
170
+ return new Date(ts * 1000).toISOString();
171
+ if (typeof ts === 'string' && ts) {
172
+ const d = new Date(/^\d+(\.\d+)?$/.test(ts) ? Number(ts) * 1000 : ts);
173
+ if (!Number.isNaN(d.getTime()))
174
+ return d.toISOString();
175
+ }
176
+ return fallback;
177
+ }
178
+ function rows(type, root) {
179
+ return projectResources(SERVICE, root).filter((r) => r.type === type);
180
+ }
181
+ let eventCounter = 0;
182
+ function genEventId(provided) {
183
+ if (provided && /^[0-9a-f]{32}$/i.test(provided.replace(/-/g, '')))
184
+ return provided.replace(/-/g, '').toLowerCase();
185
+ // 32-hex deterministic-ish id (mirrors Sentry's UUID-without-dashes event_id shape).
186
+ const seq = (++eventCounter).toString(16).padStart(8, '0');
187
+ return (seq + 'f'.repeat(24)).slice(0, 32);
188
+ }
189
+ /**
190
+ * Ingest ONE event into the twin: derive its issue group, upsert the issue (count/seen/
191
+ * status), persist the event, and return the outcome (for the caller to fire alerts).
192
+ * Pure local writes via the kernel — no network. `occurredAt` keeps it deterministic.
193
+ */
194
+ export async function ingestEvent(key, event, opts = {}) {
195
+ const occurredAt = opts.occurredAt ?? new Date().toISOString();
196
+ const eventTs = toIso(event.timestamp, occurredAt);
197
+ const eventId = genEventId(event.event_id);
198
+ const issueId = issueIdFor(key.projectId, event);
199
+ const level = levelOf(event);
200
+ const title = eventTitle(event);
201
+ const culprit = eventCulprit(event);
202
+ const tags = tagsObject(event);
203
+ const existing = rows('issue', opts.root).find((r) => r.id === issueId);
204
+ const isNew = !existing;
205
+ const prevStatus = existing ? String(existing.status ?? 'unresolved') : 'unresolved';
206
+ // A new event on a resolved/ignored issue REOPENS it (Sentry "regression").
207
+ const wasResolved = !isNew && (prevStatus === 'resolved' || prevStatus === 'ignored');
208
+ const prevCount = existing ? Number(existing.count ?? 0) : 0;
209
+ const firstSeen = existing ? String(existing.firstSeen ?? eventTs) : eventTs;
210
+ const issueFields = {
211
+ projectId: key.projectId,
212
+ projectSlug: key.projectSlug,
213
+ shortId: `${(key.projectSlug || 'PROJECT').toUpperCase()}-${issueId.slice(0, 4).toUpperCase()}`,
214
+ title,
215
+ culprit,
216
+ level,
217
+ platform: typeof event.platform === 'string' ? event.platform : 'other',
218
+ status: wasResolved ? 'unresolved' : prevStatus,
219
+ count: String(prevCount + 1),
220
+ userCount: 0,
221
+ firstSeen,
222
+ lastSeen: eventTs,
223
+ type: existing && existing.metadataType ? existing.metadataType : (exceptionValues(event).length ? 'error' : 'default'),
224
+ metadata: { type: exceptionValues(event)[exceptionValues(event).length - 1]?.type, value: exceptionValues(event)[exceptionValues(event).length - 1]?.value, title },
225
+ isUnhandled: event.level === 'fatal',
226
+ ...(existing?.assignedTo !== undefined ? { assignedTo: existing.assignedTo } : {}),
227
+ };
228
+ const { resource: issueRes } = await applyTwinWrite(SERVICE, {
229
+ operation: isNew ? 'issue.create' : 'issue.observe',
230
+ subjectType: 'issue', subjectId: issueId, fields: issueFields, occurredAt,
231
+ actor: { kind: 'system' },
232
+ }, opts.root);
233
+ const eventFields = {
234
+ eventID: eventId,
235
+ groupID: issueId,
236
+ projectID: key.projectId,
237
+ title,
238
+ culprit,
239
+ level,
240
+ platform: issueFields.platform,
241
+ message: messageText(event) ?? '',
242
+ dateCreated: eventTs,
243
+ dateReceived: occurredAt,
244
+ tags: Object.entries(tags).map(([k, v]) => ({ key: k, value: v })),
245
+ environment: typeof event.environment === 'string' ? event.environment : null,
246
+ release: typeof event.release === 'string' ? event.release : null,
247
+ 'entries:exception': exceptionValues(event),
248
+ };
249
+ await applyTwinWrite(SERVICE, {
250
+ operation: 'event.observe', subjectType: 'event', subjectId: eventId, fields: eventFields, occurredAt,
251
+ actor: { kind: 'system' },
252
+ }, opts.root);
253
+ return {
254
+ eventId, issueId, isNew, wasResolved,
255
+ issue: { id: issueId, ...issueFields },
256
+ event: { id: eventId, ...eventFields },
257
+ };
258
+ }
259
+ export function parseEnvelope(body) {
260
+ const lines = body.split('\n');
261
+ let idx = 0;
262
+ const header = safeJson(lines[idx++] ?? '{}');
263
+ const items = [];
264
+ while (idx < lines.length) {
265
+ const headerLine = lines[idx++];
266
+ if (headerLine === undefined || headerLine === '')
267
+ continue;
268
+ const itemHeader = safeJson(headerLine);
269
+ const payloadLine = lines[idx++] ?? '';
270
+ items.push({ header: itemHeader, payload: safeJson(payloadLine) });
271
+ }
272
+ return { header, items };
273
+ }
274
+ function safeJson(s) {
275
+ try {
276
+ return JSON.parse(s);
277
+ }
278
+ catch {
279
+ return {};
280
+ }
281
+ }
282
+ /** Extract the event payloads (type 'event') from a parsed envelope. */
283
+ export function envelopeEvents(env) {
284
+ return env.items
285
+ .filter((it) => it.header.type === 'event')
286
+ .map((it) => it.payload);
287
+ }
288
+ export function envelopeAttachments(env) {
289
+ return env.items
290
+ .filter((it) => it.header.type === 'attachment')
291
+ .map((it) => {
292
+ const h = it.header;
293
+ const payload = it.payload;
294
+ const declared = typeof h.length === 'number' ? h.length : undefined;
295
+ const size = declared ?? (typeof payload === 'string' ? payload.length : JSON.stringify(payload ?? '').length);
296
+ return {
297
+ name: String(h.filename ?? 'attachment'),
298
+ size,
299
+ contentType: String(h.content_type ?? 'application/octet-stream'),
300
+ attachmentType: String(h.attachment_type ?? 'event.attachment'),
301
+ };
302
+ });
303
+ }
304
+ export function envelopeSessions(env) {
305
+ return env.items
306
+ .filter((it) => it.header.type === 'session')
307
+ .map((it) => {
308
+ const p = (it.payload && typeof it.payload === 'object') ? it.payload : {};
309
+ const attrs = (p.attrs && typeof p.attrs === 'object') ? p.attrs : {};
310
+ return {
311
+ sid: String(p.sid ?? ''),
312
+ status: String(p.status ?? 'ok'),
313
+ release: typeof attrs.release === 'string' ? attrs.release : undefined,
314
+ environment: typeof attrs.environment === 'string' ? attrs.environment : undefined,
315
+ errors: typeof p.errors === 'number' ? p.errors : 0,
316
+ };
317
+ });
318
+ }
319
+ /** Persist release-health sessions as `session` resources (upsert by sid: a session is a
320
+ * lifecycle, the final status wins). This is fully reproducible offline — crash-free counts
321
+ * are exact tallies over the persisted sessions (NOT a fabricated metrics engine). */
322
+ export async function persistSessions(projectId, sessions, opts = {}) {
323
+ const occurredAt = opts.occurredAt ?? new Date().toISOString();
324
+ for (const s of sessions) {
325
+ if (!s.sid)
326
+ continue;
327
+ await applyTwinWrite(SERVICE, {
328
+ operation: 'session.observe', subjectType: 'session', subjectId: s.sid,
329
+ fields: {
330
+ projectID: projectId, status: s.status, release: s.release ?? null,
331
+ environment: s.environment ?? null, errors: s.errors, lastUpdate: occurredAt,
332
+ },
333
+ occurredAt, actor: { kind: 'system' },
334
+ }, opts.root);
335
+ }
336
+ }
337
+ /** Persist an event's attachments (metadata only) as `event_attachment` resources. */
338
+ export async function persistEventAttachments(eventId, projectId, attachments, opts = {}) {
339
+ const occurredAt = opts.occurredAt ?? new Date().toISOString();
340
+ let i = rows('event_attachment', opts.root).length;
341
+ for (const a of attachments) {
342
+ i += 1;
343
+ await applyTwinWrite(SERVICE, {
344
+ operation: 'event_attachment.create', subjectType: 'event_attachment', subjectId: `att_${i}`,
345
+ fields: {
346
+ eventId, projectID: projectId, name: a.name, size: a.size,
347
+ mimetype: a.contentType, type: a.attachmentType,
348
+ sha1: hashFingerprint(`${eventId}|${a.name}|${a.size}`),
349
+ dateCreated: occurredAt,
350
+ headers: { 'Content-Type': a.contentType },
351
+ },
352
+ occurredAt, actor: { kind: 'system' },
353
+ }, opts.root);
354
+ }
355
+ }
356
+ // ── Default project + key seeding ────────────────────────────────────────────────
357
+ // So an out-of-the-box DSN works against a fresh twin, ensure a default org/project/key
358
+ // exists. Idempotent: only writes when absent. The default public key is deterministic.
359
+ export const DEFAULT_ORG = 'twin-org';
360
+ export const DEFAULT_PROJECT = 'twin-project';
361
+ export const DEFAULT_PROJECT_ID = '1';
362
+ export const DEFAULT_PUBLIC_KEY = 'twinpublickey00000000000000000000';
363
+ export async function ensureDefaultProject(root, occurredAt) {
364
+ const ts = occurredAt ?? '1970-01-01T00:00:00.000Z';
365
+ const haveOrg = rows('organization', root).some((r) => r.slug === DEFAULT_ORG);
366
+ if (!haveOrg) {
367
+ 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);
368
+ }
369
+ const haveProj = rows('project', root).some((r) => r.id === DEFAULT_PROJECT_ID);
370
+ if (!haveProj) {
371
+ 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);
372
+ }
373
+ const haveKey = rows('project_key', root).some((r) => r.public === DEFAULT_PUBLIC_KEY);
374
+ if (!haveKey) {
375
+ await applyTwinWrite(SERVICE, {
376
+ operation: 'project_key.create', subjectType: 'project_key', subjectId: 'key_1',
377
+ fields: { public: DEFAULT_PUBLIC_KEY, secret: 'twinsecret00000000000000000000000', projectId: DEFAULT_PROJECT_ID, projectSlug: DEFAULT_PROJECT, organizationSlug: DEFAULT_ORG, name: 'Default', dateCreated: ts, isActive: true },
378
+ occurredAt: ts, actor: { kind: 'system' },
379
+ }, root);
380
+ }
381
+ }
382
+ /** The default DSN an SDK can use against a twin server at `origin` (e.g. http://127.0.0.1:PORT). */
383
+ export function defaultDsn(origin) {
384
+ const host = origin.replace(/^https?:\/\//, '');
385
+ const protocol = origin.startsWith('https') ? 'https' : 'http';
386
+ return `${protocol}://${DEFAULT_PUBLIC_KEY}@${host}/${DEFAULT_PROJECT_ID}`;
387
+ }
@@ -0,0 +1,38 @@
1
+ export type IssueRow = Record<string, any>;
2
+ /** Map a Sentry level to a UI tone class. */
3
+ export declare function levelTone(level: unknown): 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'muted';
4
+ /** Map an issue status to a UI tone class. */
5
+ export declare function statusTone(status: unknown): 'unresolved' | 'resolved' | 'ignored' | 'muted';
6
+ /** "12.3k", "1.2M", "999" — Sentry's compact event-count formatting. */
7
+ export declare function formatCount(value: unknown): string;
8
+ /** Relative "time ago" for a lastSeen/firstSeen ISO string (deterministic given `now`). */
9
+ export declare function timeAgo(iso: unknown, now?: number): string;
10
+ /** Extract the latest event's exception stacktrace frames for the detail view. */
11
+ export type StackFrame = {
12
+ filename?: string;
13
+ function?: string;
14
+ lineno?: number;
15
+ module?: string;
16
+ };
17
+ export declare function latestStacktrace(event: unknown): StackFrame[];
18
+ /** The event's tags as a flat [key,value] list for rendering. */
19
+ export declare function eventTags(event: unknown): Array<{
20
+ key: string;
21
+ value: string;
22
+ }>;
23
+ export declare function buildSentryMirrorClient(): Promise<string>;
24
+ /** Serve the Sentry dashboard mirror UI (React app) + its backing Web API. */
25
+ export declare function createSentryMirrorServer(options?: {
26
+ root?: string;
27
+ port?: number;
28
+ }): Promise<{
29
+ port: number;
30
+ stop: () => void;
31
+ }>;
32
+ /** The app-shell HTML (pure, for tests). */
33
+ export declare function sentryMirrorHtml(): string;
34
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
35
+ export declare function sentryMirrorStyles(): Promise<string>;
36
+ /** The default org/project the mirror lists issues for (matches the seeded defaults). */
37
+ export declare const MIRROR_DEFAULT_ORG = "twin-org";
38
+ export declare const MIRROR_DEFAULT_PROJECT = "twin-project";