@ericminassian/platform 0.0.0-stage → 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 (78) hide show
  1. package/dist/app.d.ts +10 -0
  2. package/dist/app.js +23 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/auth/cookies.d.ts +10 -0
  5. package/dist/auth/cookies.js +37 -0
  6. package/dist/auth/cookies.js.map +1 -0
  7. package/dist/auth/index.d.ts +22 -0
  8. package/dist/auth/index.js +217 -0
  9. package/dist/auth/index.js.map +1 -0
  10. package/dist/auth/pkce.d.ts +16 -0
  11. package/dist/auth/pkce.js +32 -0
  12. package/dist/auth/pkce.js.map +1 -0
  13. package/dist/auth/tokens.d.ts +17 -0
  14. package/dist/auth/tokens.js +142 -0
  15. package/dist/auth/tokens.js.map +1 -0
  16. package/dist/bindings.d.ts +13 -0
  17. package/dist/bindings.js +32 -0
  18. package/dist/bindings.js.map +1 -0
  19. package/dist/clients.d.ts +38 -0
  20. package/dist/clients.js +65 -0
  21. package/dist/clients.js.map +1 -0
  22. package/dist/context.d.ts +31 -0
  23. package/dist/context.js +33 -0
  24. package/dist/context.js.map +1 -0
  25. package/dist/db.d.ts +97 -0
  26. package/dist/db.js +297 -0
  27. package/dist/db.js.map +1 -0
  28. package/dist/events.d.ts +9 -0
  29. package/dist/events.js +28 -0
  30. package/dist/events.js.map +1 -0
  31. package/dist/http.d.ts +14 -0
  32. package/dist/http.js +82 -0
  33. package/dist/http.js.map +1 -0
  34. package/dist/index.d.ts +30 -0
  35. package/dist/index.js +11 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/queue.d.ts +7 -0
  38. package/dist/queue.js +50 -0
  39. package/dist/queue.js.map +1 -0
  40. package/dist/secrets.d.ts +8 -0
  41. package/dist/secrets.js +42 -0
  42. package/dist/secrets.js.map +1 -0
  43. package/dist/server.d.ts +12 -0
  44. package/dist/server.js +232 -0
  45. package/dist/server.js.map +1 -0
  46. package/dist/storage.d.ts +30 -0
  47. package/dist/storage.js +108 -0
  48. package/dist/storage.js.map +1 -0
  49. package/dist/telemetry.d.ts +42 -0
  50. package/dist/telemetry.js +218 -0
  51. package/dist/telemetry.js.map +1 -0
  52. package/dist/testing.d.ts +31 -0
  53. package/dist/testing.js +54 -0
  54. package/dist/testing.js.map +1 -0
  55. package/dist/worker.d.ts +73 -0
  56. package/dist/worker.js +179 -0
  57. package/dist/worker.js.map +1 -0
  58. package/package.json +58 -3
  59. package/src/app.ts +32 -0
  60. package/src/auth/cookies.ts +39 -0
  61. package/src/auth/index.ts +246 -0
  62. package/src/auth/pkce.ts +43 -0
  63. package/src/auth/tokens.ts +174 -0
  64. package/src/bindings.ts +35 -0
  65. package/src/clients.ts +98 -0
  66. package/src/context.ts +56 -0
  67. package/src/db.ts +380 -0
  68. package/src/events.ts +42 -0
  69. package/src/http.ts +82 -0
  70. package/src/index.ts +40 -0
  71. package/src/queue.ts +67 -0
  72. package/src/secrets.ts +57 -0
  73. package/src/server.ts +274 -0
  74. package/src/storage.ts +143 -0
  75. package/src/telemetry.ts +260 -0
  76. package/src/testing.ts +73 -0
  77. package/src/worker.ts +291 -0
  78. package/README.md +0 -3
package/src/server.ts ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Web Function entry wrapper. `plat build` generates:
3
+ *
4
+ * import handler from '<app>/src/server.ts';
5
+ * import { startServer } from '@ericminassian/platform/server';
6
+ * startServer(handler);
7
+ *
8
+ * and bundles it to `index.mjs`. startServer listens on
9
+ * `AWS_LAMBDA_HTTP_ENDPOINT` (Web Function contract; `plat dev` sets it too),
10
+ * adds `/__platform/*` routes (health, auth), auto-instruments every request
11
+ * (telemetry `req`/`err`/`span` lines, traces) and maps `x-platform-host` to
12
+ * the public URL the app sees.
13
+ *
14
+ * CONTRACT: used by the CLI build. Keep stable.
15
+ */
16
+ import {
17
+ fingerprint,
18
+ HEALTH_PATH,
19
+ HOST_HEADER,
20
+ PLATFORM_PATH_PREFIX,
21
+ TRACE_HEADER,
22
+ } from '@ericminassian/platform-core';
23
+ import { authGate, handleAuthRoute } from './auth/index.ts';
24
+ import { getBindings, tryBindings } from './bindings.ts';
25
+ import { currentContext, newContext, parseTraceparent, runWithContext } from './context.ts';
26
+ import { createNodeServer, parseEndpoint } from './http.ts';
27
+ import { emit, emitError, installConsoleCapture, installFetchInstrumentation, toError } from './telemetry.ts';
28
+
29
+ /** Anything with a web-standard fetch: a Hono app, `{ fetch }`, or a bare function. */
30
+ export type FetchHandlerLike =
31
+ | { fetch(request: Request, ...rest: unknown[]): Response | Promise<Response> }
32
+ | ((request: Request) => Response | Promise<Response>);
33
+
34
+ interface RouteLike {
35
+ path: string;
36
+ method: string;
37
+ }
38
+
39
+ let cold = true;
40
+
41
+ /** The wrapped fetch (platform routes + instrumentation), without listening. Useful in tests. */
42
+ export function createPlatformFetch(handler: FetchHandlerLike): (request: Request) => Promise<Response> {
43
+ const appFetch =
44
+ typeof handler === 'function'
45
+ ? handler
46
+ : (req: Request) => (handler as { fetch: (r: Request) => Response | Promise<Response> }).fetch(req);
47
+ const matchRoute = routeMatcher(handler);
48
+ instrumentHonoErrors(handler);
49
+
50
+ return async (incoming: Request): Promise<Response> => {
51
+ const t0 = performance.now();
52
+ const trace = incoming.headers.get(TRACE_HEADER) ?? parseTraceparent(incoming.headers.get('traceparent'));
53
+ const ctx = newContext(trace);
54
+ const wasCold = cold;
55
+ cold = false;
56
+
57
+ return runWithContext(ctx, async () => {
58
+ const request = publicRequest(incoming);
59
+ const url = new URL(request.url);
60
+ let response: Response;
61
+ let errorEmitted = false;
62
+ try {
63
+ if (url.pathname.startsWith(PLATFORM_PATH_PREFIX)) {
64
+ response = await platformRoute(request);
65
+ } else {
66
+ response = (await authGate(request)) ?? (await appFetch(request));
67
+ }
68
+ } catch (e) {
69
+ if (e instanceof Response) response = e;
70
+ else {
71
+ emitError(e, 'http', { route: ctx.route ?? matchRoute(request.method, url.pathname) });
72
+ errorEmitted = true;
73
+ response = Response.json(
74
+ { error: { code: 'internal', message: 'Internal Server Error', trace: ctx.trace } },
75
+ { status: 500 },
76
+ );
77
+ }
78
+ }
79
+
80
+ response = finalize(response, ctx.trace, ctx.cookies);
81
+ const route = ctx.route ?? matchRoute(request.method, url.pathname);
82
+ if (response.status >= 500 && !errorEmitted) {
83
+ if (ctx.error !== undefined) emitError(ctx.error, 'http', { route });
84
+ else {
85
+ const e = new Error(`HTTP ${response.status} ${request.method} ${route}`);
86
+ e.name = 'HttpError';
87
+ emitError(e, 'http', { route });
88
+ }
89
+ }
90
+ emit('req', {
91
+ method: request.method,
92
+ route,
93
+ path: url.pathname,
94
+ status: response.status,
95
+ dur: Math.round((performance.now() - t0) * 10) / 10,
96
+ ...(lengthOf(response) !== undefined ? { bytes: lengthOf(response)! } : {}),
97
+ ...(incoming.headers.get('cloudfront-viewer-country')
98
+ ? { country: incoming.headers.get('cloudfront-viewer-country')! }
99
+ : {}),
100
+ ...(ctx.user ? { user: fingerprint([ctx.user.id]) } : {}),
101
+ ...(wasCold ? { cold: true } : {}),
102
+ span: ctx.span,
103
+ });
104
+ return response;
105
+ });
106
+ };
107
+ }
108
+
109
+ /** Rewrite the request onto the app's public origin (scheme/port from bindings, host from the router). */
110
+ function publicRequest(incoming: Request): Request {
111
+ const b = tryBindings();
112
+ if (!b) return incoming;
113
+ const inUrl = new URL(incoming.url);
114
+ const origin = new URL(b.url);
115
+ const forwarded = incoming.headers.get(HOST_HEADER);
116
+ if (forwarded && /^[a-z0-9.-]+$/i.test(forwarded.replace(/:\d+$/, '')))
117
+ origin.hostname = forwarded.replace(/:\d+$/, '');
118
+ const target = `${origin.origin}${inUrl.pathname}${inUrl.search}`;
119
+ if (target === incoming.url) return incoming;
120
+ const hasBody = incoming.method !== 'GET' && incoming.method !== 'HEAD';
121
+ return new Request(target, {
122
+ method: incoming.method,
123
+ headers: incoming.headers,
124
+ signal: incoming.signal,
125
+ redirect: incoming.redirect,
126
+ ...(hasBody ? { body: incoming.body, duplex: 'half' } : {}),
127
+ } as RequestInit);
128
+ }
129
+
130
+ async function platformRoute(request: Request): Promise<Response> {
131
+ const url = new URL(request.url);
132
+ if (url.pathname === HEALTH_PATH) {
133
+ const b = getBindings();
134
+ return Response.json(
135
+ { ok: true, app: b.app, stage: b.stage, revision: b.revision },
136
+ { headers: { 'cache-control': 'no-store' } },
137
+ );
138
+ }
139
+ const authResponse = await handleAuthRoute(request);
140
+ if (authResponse) return authResponse;
141
+ return Response.json({ error: { code: 'not_found', message: 'Not found' } }, { status: 404 });
142
+ }
143
+
144
+ function finalize(response: Response, trace: string, cookies: string[]): Response {
145
+ const headers = new Headers(response.headers);
146
+ headers.set(TRACE_HEADER, trace);
147
+ if (!headers.has('cache-control')) headers.set('cache-control', 'private, no-store');
148
+ for (const c of cookies) headers.append('set-cookie', c);
149
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
150
+ }
151
+
152
+ function lengthOf(r: Response): number | undefined {
153
+ const v = r.headers.get('content-length');
154
+ return v ? Number(v) : undefined;
155
+ }
156
+
157
+ // ---------------------------------------------------------------- framework integration
158
+
159
+ type ErrorHandler = (err: unknown, c: unknown) => Response | Promise<Response>;
160
+
161
+ /**
162
+ * Hono catches handler errors itself (default: plain-text 500). Hook its
163
+ * onError so errors reach telemetry with their real stack, and so apps using
164
+ * the default handler get the platform's JSON 500 with a trace id. A custom
165
+ * onError the app installed keeps control of the response.
166
+ */
167
+ function instrumentHonoErrors(handler: FetchHandlerLike): void {
168
+ const h = handler as {
169
+ onError?: (fn: ErrorHandler) => unknown;
170
+ errorHandler?: ErrorHandler;
171
+ __platform?: boolean;
172
+ };
173
+ if (typeof h.onError !== 'function' || h.__platform) return;
174
+ const prev = h.errorHandler;
175
+ const prevIsDefault =
176
+ typeof prev !== 'function' ||
177
+ prev.name === 'errorHandler' ||
178
+ String(prev).includes('Internal Server Error');
179
+ h.onError((err, c) => {
180
+ if (err && typeof (err as { getResponse?: unknown }).getResponse === 'function') {
181
+ return prev ? prev(err, c) : (err as { getResponse(): Response }).getResponse();
182
+ }
183
+ const ctx = currentContext();
184
+ if (ctx) ctx.error = err;
185
+ if (prev && !prevIsDefault) return prev(err, c);
186
+ throw err;
187
+ });
188
+ h.__platform = true;
189
+ }
190
+
191
+ // ---------------------------------------------------------------- route patterns
192
+
193
+ const ID_SEGMENT =
194
+ /^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{12,}|[0-9a-hjkmnp-tv-z]{26}|(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{16,})$/i;
195
+
196
+ /** `/api/todos/42` → `/api/todos/:id` (used when the framework's route pattern is unknown). */
197
+ export function normalizePath(path: string): string {
198
+ return path
199
+ .split('/')
200
+ .map((seg) => (seg && ID_SEGMENT.test(seg) ? ':id' : seg))
201
+ .join('/');
202
+ }
203
+
204
+ /** Hono apps expose `routes`; match against them to recover the route pattern. */
205
+ function routeMatcher(handler: FetchHandlerLike): (method: string, path: string) => string {
206
+ const routes = (handler as { routes?: RouteLike[] }).routes;
207
+ if (!Array.isArray(routes)) return (_m, path) => normalizePath(path);
208
+ let compiled: Array<{ method: string; path: string; re: RegExp }> | undefined;
209
+ return (method, path) => {
210
+ compiled ??= routes
211
+ .filter((r) => r.method !== 'ALL' && typeof r.path === 'string' && !/^\/?\*?$/.test(r.path))
212
+ .map((r) => ({ method: r.method, path: r.path, re: patternToRegex(r.path) }));
213
+ const hit = compiled.find(
214
+ (r) => (r.method === method || (method === 'HEAD' && r.method === 'GET')) && r.re.test(path),
215
+ );
216
+ return hit ? hit.path : normalizePath(path);
217
+ };
218
+ }
219
+
220
+ function patternToRegex(pattern: string): RegExp {
221
+ const body = pattern
222
+ .split('/')
223
+ .map((seg) => {
224
+ if (seg === '*') return '.*';
225
+ if (seg.startsWith(':')) {
226
+ const custom = /\{(.+)\}/.exec(seg);
227
+ const optional = seg.endsWith('?');
228
+ const part = custom ? `(?:${custom[1]})` : '[^/]+';
229
+ return optional ? `?(?:${part})?` : part;
230
+ }
231
+ return seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*');
232
+ })
233
+ .join('/');
234
+ return new RegExp(`^${body}/?$`);
235
+ }
236
+
237
+ // ---------------------------------------------------------------- server
238
+
239
+ /** Start the HTTP server. Resolves once listening. */
240
+ export async function startServer(handler: FetchHandlerLike): Promise<{ close(): Promise<void> }> {
241
+ installConsoleCapture();
242
+ installFetchInstrumentation();
243
+ const endpoint =
244
+ process.env.AWS_LAMBDA_HTTP_ENDPOINT ??
245
+ (process.env.PORT ? `127.0.0.1:${process.env.PORT}` : '127.0.0.1:8080');
246
+ const { host, port } = parseEndpoint(endpoint);
247
+ const server = createNodeServer(createPlatformFetch(handler));
248
+ server.keepAliveTimeout = 65_000;
249
+
250
+ await new Promise<void>((resolve, reject) => {
251
+ server.once('error', reject);
252
+ server.listen(port, host, () => {
253
+ server.off('error', reject);
254
+ resolve();
255
+ });
256
+ });
257
+ emit('env', { event: 'start', runtime: 'web', initMs: Math.round(performance.now()) });
258
+
259
+ const close = () =>
260
+ new Promise<void>((resolve) => {
261
+ server.close(() => resolve());
262
+ server.closeIdleConnections?.();
263
+ });
264
+
265
+ process.once('SIGTERM', () => {
266
+ emit('env', { event: 'stop', runtime: 'web' });
267
+ // Web Functions allow ~1 s for cleanup after SIGTERM.
268
+ const force = setTimeout(() => process.exit(0), 900);
269
+ force.unref();
270
+ close().then(() => process.exit(0));
271
+ });
272
+ process.on('unhandledRejection', (reason) => emitError(toError(reason), 'unhandled'));
273
+ return { close };
274
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { getBindings } from './bindings.ts';
2
+ import { client } from './clients.ts';
3
+ import { span } from './telemetry.ts';
4
+
5
+ export interface StoredObject {
6
+ key: string;
7
+ size: number;
8
+ lastModified?: Date;
9
+ }
10
+
11
+ export interface Store {
12
+ put(key: string, body: Uint8Array | string, opts?: { contentType?: string }): Promise<void>;
13
+ get(key: string): Promise<Uint8Array | undefined>;
14
+ getText(key: string): Promise<string | undefined>;
15
+ delete(key: string): Promise<void>;
16
+ list(
17
+ prefix?: string,
18
+ opts?: { cursor?: string; limit?: number },
19
+ ): Promise<{ objects: StoredObject[]; cursor?: string }>;
20
+ /** Browser-direct upload URL (HTTP PUT). */
21
+ uploadUrl(key: string, opts?: { contentType?: string; expiresInSeconds?: number }): Promise<string>;
22
+ downloadUrl(key: string, opts?: { expiresInSeconds?: number; filename?: string }): Promise<string>;
23
+ }
24
+
25
+ function location(name: string): { bucket: string; prefix: string } {
26
+ const s = getBindings().storage;
27
+ if (!s?.stores.includes(name)) {
28
+ const declared = s?.stores ?? [];
29
+ throw new Error(
30
+ `Unknown store "${name}". Declared storage: ${declared.length ? declared.join(', ') : '(none)'} — add it under "storage" in platform.json.`,
31
+ );
32
+ }
33
+ return { bucket: s.bucket, prefix: `${s.prefix}${name}/` };
34
+ }
35
+
36
+ /** The S3 client and command module, loaded on first use. */
37
+ async function s3() {
38
+ const [c, m] = await Promise.all([client('s3'), import('@aws-sdk/client-s3')]);
39
+ return { c, m };
40
+ }
41
+
42
+ async function sign(command: unknown, expiresIn: number): Promise<string> {
43
+ const [{ getSignedUrl }, c] = await Promise.all([import('@aws-sdk/s3-request-presigner'), client('s3')]);
44
+ // biome-ignore lint/suspicious/noExplicitAny: presigner and client generics differ across smithy versions
45
+ return getSignedUrl(c as any, command as any, { expiresIn });
46
+ }
47
+
48
+ function objectKey(prefix: string, key: string): string {
49
+ const k = key.replace(/^\/+/, '');
50
+ if (!k || k.split('/').some((p) => p === '..')) throw new Error(`Invalid storage key "${key}"`);
51
+ return prefix + k;
52
+ }
53
+
54
+ function isNotFound(e: unknown): boolean {
55
+ const name = (e as { name?: string })?.name;
56
+ const status = (e as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
57
+ return name === 'NoSuchKey' || name === 'NotFound' || status === 404;
58
+ }
59
+
60
+ export function storage(name: string): Store {
61
+ const get = (key: string) =>
62
+ span('storage.get', name, async () => {
63
+ const { bucket, prefix } = location(name);
64
+ const { c, m } = await s3();
65
+ try {
66
+ const out = await c.send(new m.GetObjectCommand({ Bucket: bucket, Key: objectKey(prefix, key) }));
67
+ return out.Body ? await out.Body.transformToByteArray() : new Uint8Array();
68
+ } catch (e) {
69
+ if (isNotFound(e)) return undefined;
70
+ throw e;
71
+ }
72
+ });
73
+
74
+ return {
75
+ put: (key, body, opts) =>
76
+ span('storage.put', name, async () => {
77
+ const { bucket, prefix } = location(name);
78
+ const { c, m } = await s3();
79
+ await c.send(
80
+ new m.PutObjectCommand({
81
+ Bucket: bucket,
82
+ Key: objectKey(prefix, key),
83
+ Body: typeof body === 'string' ? Buffer.from(body) : body,
84
+ ...(opts?.contentType ? { ContentType: opts.contentType } : {}),
85
+ }),
86
+ );
87
+ }),
88
+ get,
89
+ getText: async (key) => {
90
+ const bytes = await get(key);
91
+ return bytes ? new TextDecoder().decode(bytes) : undefined;
92
+ },
93
+ delete: (key) =>
94
+ span('storage.delete', name, async () => {
95
+ const { bucket, prefix } = location(name);
96
+ const { c, m } = await s3();
97
+ await c.send(new m.DeleteObjectCommand({ Bucket: bucket, Key: objectKey(prefix, key) }));
98
+ }),
99
+ list: (sub, opts) =>
100
+ span('storage.list', name, async () => {
101
+ const { bucket, prefix } = location(name);
102
+ const { c, m } = await s3();
103
+ const out = await c.send(
104
+ new m.ListObjectsV2Command({
105
+ Bucket: bucket,
106
+ Prefix: prefix + (sub ?? '').replace(/^\/+/, ''),
107
+ ...(opts?.cursor ? { ContinuationToken: opts.cursor } : {}),
108
+ ...(opts?.limit ? { MaxKeys: opts.limit } : {}),
109
+ }),
110
+ );
111
+ const objects: StoredObject[] = (out.Contents ?? []).map((o) => ({
112
+ key: (o.Key ?? '').slice(prefix.length),
113
+ size: o.Size ?? 0,
114
+ ...(o.LastModified ? { lastModified: o.LastModified } : {}),
115
+ }));
116
+ return out.NextContinuationToken ? { objects, cursor: out.NextContinuationToken } : { objects };
117
+ }),
118
+ uploadUrl: (key, opts) =>
119
+ span('storage.sign', name, async () => {
120
+ const { bucket, prefix } = location(name);
121
+ const { m } = await s3();
122
+ const cmd = new m.PutObjectCommand({
123
+ Bucket: bucket,
124
+ Key: objectKey(prefix, key),
125
+ ...(opts?.contentType ? { ContentType: opts.contentType } : {}),
126
+ });
127
+ return sign(cmd, opts?.expiresInSeconds ?? 900);
128
+ }),
129
+ downloadUrl: (key, opts) =>
130
+ span('storage.sign', name, async () => {
131
+ const { bucket, prefix } = location(name);
132
+ const { m } = await s3();
133
+ const cmd = new m.GetObjectCommand({
134
+ Bucket: bucket,
135
+ Key: objectKey(prefix, key),
136
+ ...(opts?.filename
137
+ ? { ResponseContentDisposition: `attachment; filename="${opts.filename.replace(/"/g, '')}"` }
138
+ : {}),
139
+ });
140
+ return sign(cmd, opts?.expiresInSeconds ?? 900);
141
+ }),
142
+ };
143
+ }
@@ -0,0 +1,260 @@
1
+ import { format } from 'node:util';
2
+ import {
3
+ type ErrorEvent,
4
+ errorFingerprint,
5
+ newSpanId,
6
+ type TelemetryEvent,
7
+ type TelemetryKind,
8
+ } from '@ericminassian/platform-core';
9
+ import { tryBindings } from './bindings.ts';
10
+ import { currentContext } from './context.ts';
11
+
12
+ /**
13
+ * Telemetry writer. Every line is one core `TelemetryEvent` as JSON on stdout
14
+ * (Lambda ships stdout to CloudWatch Logs; the account-level subscription
15
+ * filter forwards `_p: 1` lines to the telemetry processor). Under `plat dev`
16
+ * (`PLATFORM_LOG_PRETTY=1`) lines are printed compactly for humans instead.
17
+ * Nothing here may ever throw into app code.
18
+ */
19
+
20
+ type Sink = (event: TelemetryEvent) => void;
21
+
22
+ // Captured before console is patched so telemetry never recurses through it.
23
+ const rawWrite = process.stdout.write.bind(process.stdout);
24
+ let sink: Sink | null = null;
25
+
26
+ /** Replace the output (tests). `null` restores stdout. */
27
+ export function setTelemetrySink(s: Sink | null): void {
28
+ sink = s;
29
+ }
30
+
31
+ type Fields<K extends TelemetryKind> = Omit<
32
+ Extract<TelemetryEvent, { t: K }>,
33
+ '_p' | 'ts' | 'app' | 'stage' | 'rev' | 't'
34
+ > & {
35
+ ts?: number;
36
+ };
37
+
38
+ export function emit<K extends TelemetryKind>(t: K, fields: Fields<K>): void {
39
+ try {
40
+ const b = tryBindings();
41
+ const ctx = currentContext();
42
+ const event = {
43
+ _p: 1,
44
+ t,
45
+ ts: Date.now(),
46
+ app: b?.app ?? process.env.PLATFORM_APP ?? 'unknown',
47
+ stage: b?.stage ?? process.env.PLATFORM_STAGE ?? 'unknown',
48
+ rev: b?.revision ?? 'unknown',
49
+ ...(ctx ? { trace: ctx.trace } : {}),
50
+ ...fields,
51
+ } as unknown as TelemetryEvent;
52
+ if (sink) sink(event);
53
+ else rawWrite(`${process.env.PLATFORM_LOG_PRETTY === '1' ? pretty(event) : JSON.stringify(event)}\n`);
54
+ } catch {
55
+ // never throw from telemetry
56
+ }
57
+ }
58
+
59
+ /** Time `fn` as a span (`db.get`, `queue.send`, ...) under the current trace. */
60
+ export async function span<T>(op: string, name: string | undefined, fn: () => Promise<T>): Promise<T> {
61
+ const t0 = performance.now();
62
+ const ctx = currentContext();
63
+ const id = newSpanId();
64
+ let status: 'ok' | 'error' = 'ok';
65
+ try {
66
+ return await fn();
67
+ } catch (e) {
68
+ status = 'error';
69
+ throw e;
70
+ } finally {
71
+ // Spans only matter as part of a trace; skip them outside a request/job.
72
+ if (ctx)
73
+ emit('span', {
74
+ op,
75
+ ...(name ? { name } : {}),
76
+ dur: round(performance.now() - t0),
77
+ status,
78
+ span: id,
79
+ parent: ctx.span,
80
+ start: round(t0 - ctx.startedAt),
81
+ });
82
+ }
83
+ }
84
+
85
+ /** Emit an `err` event for a thrown value. */
86
+ export function emitError(
87
+ err: unknown,
88
+ source: ErrorEvent['source'],
89
+ extra: { route?: string; job?: string } = {},
90
+ ): void {
91
+ const e = toError(err);
92
+ emit('err', {
93
+ source,
94
+ name: e.name,
95
+ message: e.message,
96
+ ...(e.stack ? { stack: e.stack } : {}),
97
+ ...extra,
98
+ fingerprint: errorFingerprint(e.name, e.message, e.stack),
99
+ });
100
+ }
101
+
102
+ export function toError(err: unknown): Error {
103
+ if (err instanceof Error) return err;
104
+ return new Error(typeof err === 'string' ? err : safeStringify(err));
105
+ }
106
+
107
+ // ---------------------------------------------------------------- metrics & log
108
+
109
+ export const metrics = {
110
+ count(name: string, n = 1): void {
111
+ emit('metric', { name, value: n, unit: 'count' });
112
+ },
113
+ value(name: string, value: number, unit?: string): void {
114
+ emit('metric', { name, value, ...(unit ? { unit } : {}) });
115
+ },
116
+ };
117
+
118
+ type Level = 'debug' | 'info' | 'warn' | 'error';
119
+
120
+ function logAt(level: Level, msg: string, fields?: unknown): void {
121
+ if (
122
+ level === 'error' &&
123
+ (fields instanceof Error || (fields && typeof fields === 'object' && 'stack' in fields))
124
+ ) {
125
+ const e = toError(fields);
126
+ emit('log', { level, msg, error: { name: e.name, message: e.message, stack: e.stack } });
127
+ emitError(e, 'log', jobOrRoute());
128
+ return;
129
+ }
130
+ const extra =
131
+ fields && typeof fields === 'object'
132
+ ? (fields as Record<string, unknown>)
133
+ : fields === undefined
134
+ ? {}
135
+ : { value: fields };
136
+ emit('log', { ...extra, level, msg });
137
+ }
138
+
139
+ export const log = {
140
+ debug: (msg: string, fields?: Record<string, unknown>) => logAt('debug', msg, fields),
141
+ info: (msg: string, fields?: Record<string, unknown>) => logAt('info', msg, fields),
142
+ warn: (msg: string, fields?: Record<string, unknown>) => logAt('warn', msg, fields),
143
+ error: (msg: string, fields?: Record<string, unknown> | unknown) => logAt('error', msg, fields),
144
+ };
145
+
146
+ function jobOrRoute(): { route?: string; job?: string } {
147
+ const ctx = currentContext();
148
+ if (!ctx) return {};
149
+ return { ...(ctx.route ? { route: ctx.route } : {}), ...(ctx.job ? { job: ctx.job } : {}) };
150
+ }
151
+
152
+ // ---------------------------------------------------------------- console capture
153
+
154
+ let consolePatched = false;
155
+ const originalConsole: Partial<Record<keyof Console, unknown>> = {};
156
+
157
+ /** Route console.* through telemetry so app logs carry the trace id. */
158
+ export function installConsoleCapture(): void {
159
+ if (consolePatched) return;
160
+ consolePatched = true;
161
+ const map: Array<[keyof Console, Level]> = [
162
+ ['log', 'info'],
163
+ ['info', 'info'],
164
+ ['debug', 'debug'],
165
+ ['warn', 'warn'],
166
+ ['error', 'error'],
167
+ ];
168
+ for (const [method, level] of map) {
169
+ originalConsole[method] = console[method];
170
+ (console as unknown as Record<string, (...a: unknown[]) => void>)[method] = (...args: unknown[]) => {
171
+ emit('log', { level, msg: format(...args) });
172
+ };
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------- fetch spans
177
+
178
+ let fetchPatched = false;
179
+ let originalFetch: typeof fetch | undefined;
180
+
181
+ /** Record outgoing `fetch` calls as spans (method + host only; no URLs with secrets). */
182
+ export function installFetchInstrumentation(): void {
183
+ if (fetchPatched || typeof globalThis.fetch !== 'function') return;
184
+ fetchPatched = true;
185
+ originalFetch = globalThis.fetch;
186
+ const original = globalThis.fetch.bind(globalThis);
187
+ globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
188
+ if (!currentContext()) return original(input, init);
189
+ let name = 'fetch';
190
+ try {
191
+ const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
192
+ const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
193
+ name = `${method.toUpperCase()} ${url.host}`;
194
+ } catch {}
195
+ return span('fetch', name, () => original(input, init));
196
+ }) as typeof fetch;
197
+ }
198
+
199
+ /** Undo console/fetch patching (tests). */
200
+ export function uninstallInstrumentation(): void {
201
+ if (consolePatched) {
202
+ for (const [k, v] of Object.entries(originalConsole))
203
+ (console as unknown as Record<string, unknown>)[k] = v;
204
+ consolePatched = false;
205
+ }
206
+ if (fetchPatched && originalFetch) {
207
+ globalThis.fetch = originalFetch;
208
+ fetchPatched = false;
209
+ }
210
+ }
211
+
212
+ // ---------------------------------------------------------------- pretty printing
213
+
214
+ const COLORS = {
215
+ dim: '\x1b[2m',
216
+ red: '\x1b[31m',
217
+ yellow: '\x1b[33m',
218
+ green: '\x1b[32m',
219
+ cyan: '\x1b[36m',
220
+ reset: '\x1b[0m',
221
+ };
222
+
223
+ function pretty(e: TelemetryEvent): string {
224
+ const time = new Date(e.ts).toISOString().slice(11, 23);
225
+ const c = (color: keyof typeof COLORS, s: string) => `${COLORS[color]}${s}${COLORS.reset}`;
226
+ switch (e.t) {
227
+ case 'req': {
228
+ const color = e.status >= 500 ? 'red' : e.status >= 400 ? 'yellow' : 'green';
229
+ return `${c('dim', time)} ${c(color, String(e.status))} ${e.method} ${e.path} ${c('dim', `${e.dur}ms${e.cold ? ' cold' : ''}`)}`;
230
+ }
231
+ case 'err':
232
+ return `${c('dim', time)} ${c('red', `✖ ${e.name}: ${e.message}`)}${e.stack ? `\n${c('dim', e.stack.split('\n').slice(1, 6).join('\n'))}` : ''}`;
233
+ case 'job':
234
+ return `${c('dim', time)} ${c(e.status === 'ok' ? 'cyan' : 'red', `${e.kind}:${e.name}`)} ${e.status} ${c('dim', `${e.dur}ms`)}`;
235
+ case 'span':
236
+ return `${c('dim', `${time} ↳ ${e.op}${e.name ? ` ${e.name}` : ''} ${e.dur}ms${e.status === 'error' ? ' error' : ''}`)}`;
237
+ case 'log': {
238
+ const { _p, t, ts, app, stage, rev, trace, span: _s, parent, level, msg, ...rest } = e;
239
+ const extra = Object.keys(rest).length ? ` ${c('dim', safeStringify(rest))}` : '';
240
+ const color = level === 'error' ? 'red' : level === 'warn' ? 'yellow' : 'reset';
241
+ return `${c('dim', time)} ${c(color, msg)}${extra}`;
242
+ }
243
+ case 'metric':
244
+ return `${c('dim', `${time} metric ${e.name}=${e.value}${e.unit ? ` ${e.unit}` : ''}`)}`;
245
+ case 'env':
246
+ return `${c('dim', `${time} ${e.runtime} ${e.event}${e.initMs !== undefined ? ` (${e.initMs}ms)` : ''}`)}`;
247
+ }
248
+ }
249
+
250
+ function round(ms: number): number {
251
+ return Math.round(ms * 10) / 10;
252
+ }
253
+
254
+ function safeStringify(v: unknown): string {
255
+ try {
256
+ return JSON.stringify(v);
257
+ } catch {
258
+ return String(v);
259
+ }
260
+ }