@lowerdeck/rpc-server 1.0.7 → 1.0.9

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 (3) hide show
  1. package/package.json +5 -4
  2. package/src/rpcMux.ts +290 -184
  3. package/src/server.ts +110 -39
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowerdeck/rpc-server",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -36,12 +36,13 @@
36
36
  "vitest": "^3.2.4"
37
37
  },
38
38
  "dependencies": {
39
- "@lowerdeck/error": "^1.0.10",
40
- "@lowerdeck/execution-context": "^1.0.2",
41
- "@lowerdeck/id": "^1.0.6",
39
+ "@lowerdeck/error": "^1.2.0",
40
+ "@lowerdeck/execution-context": "^1.0.4",
41
+ "@lowerdeck/id": "^1.0.7",
42
42
  "@lowerdeck/memo": "^1.0.4",
43
43
  "@lowerdeck/sentry": "^1.0.2",
44
44
  "@lowerdeck/serialize": "^1.0.4",
45
+ "@lowerdeck/telemetry": "^1.0.1",
45
46
  "@lowerdeck/validation": "^1.0.4",
46
47
  "cookie": "^1.1.1"
47
48
  }
package/src/rpcMux.ts CHANGED
@@ -9,6 +9,14 @@ import { generateCustomId } from '@lowerdeck/id';
9
9
  import { memo } from '@lowerdeck/memo';
10
10
  import { getSentry } from '@lowerdeck/sentry';
11
11
  import { serialize } from '@lowerdeck/serialize';
12
+ import {
13
+ isTelemetryEnabled,
14
+ otelContext,
15
+ propagation,
16
+ SpanKind,
17
+ SpanStatusCode,
18
+ trace
19
+ } from '@lowerdeck/telemetry';
12
20
  import { v } from '@lowerdeck/validation';
13
21
  import * as Cookie from 'cookie';
14
22
  import { ServiceRequest } from './controller';
@@ -17,6 +25,7 @@ import { parseForwardedFor } from './extractIp';
17
25
  let verbose = process.env.NODE_ENV !== 'production';
18
26
 
19
27
  let Sentry = getSentry();
28
+ let tracer = trace.getTracer('lowerdeck.rpc-server');
20
29
 
21
30
  let validation = v.object({
22
31
  calls: v.array(
@@ -28,9 +37,38 @@ let validation = v.object({
28
37
  )
29
38
  });
30
39
 
40
+ let summarizeRpcTarget = (url: URL, body: any) => {
41
+ let pathParts = url.pathname.split('/').filter(Boolean);
42
+ let lastPart = pathParts[pathParts.length - 1] ?? '';
43
+
44
+ if (lastPart.startsWith('$') && lastPart.length > 1) {
45
+ return lastPart.slice(1);
46
+ }
47
+
48
+ let callNames: string[] = [];
49
+ if (body && typeof body == 'object' && Array.isArray(body.calls)) {
50
+ callNames = body.calls
51
+ .map((call: { name?: unknown }) =>
52
+ typeof call?.name == 'string' ? call.name.trim() : ''
53
+ )
54
+ .filter(Boolean);
55
+ }
56
+
57
+ if (callNames.length == 1) return callNames[0];
58
+
59
+ if (callNames.length > 1) {
60
+ let preview = callNames.slice(0, 3).join(', ');
61
+ if (callNames.length > 3) return `${preview}, +${callNames.length - 3} more`;
62
+ return preview;
63
+ }
64
+
65
+ return url.pathname;
66
+ };
67
+
31
68
  export let rpcMux = (
32
69
  opts: {
33
70
  path: string;
71
+ allowRootSpan?: boolean;
34
72
  cors?: {
35
73
  headers?: string[];
36
74
  } & ({ domains: string[] } | { check: (origin: string) => boolean });
@@ -87,7 +125,7 @@ export let rpcMux = (
87
125
  ? {
88
126
  'access-control-allow-origin': origin,
89
127
  'access-control-allow-methods': 'POST, OPTIONS',
90
- 'access-control-allow-headers': `Content-Type, Authorization, Baggage, Sentry-Trace${
128
+ 'access-control-allow-headers': `Content-Type, Authorization, Baggage, Sentry-Trace, traceparent, tracestate${
91
129
  additionalCorsHeaders ?? ''
92
130
  }`,
93
131
  'access-control-max-age': '604800',
@@ -154,137 +192,121 @@ export let rpcMux = (
154
192
  }
155
193
  }
156
194
 
157
- return await Sentry.withIsolationScope(
158
- async () =>
159
- await Sentry.continueTrace(
160
- { sentryTrace, baggage },
161
- async () =>
162
- await Sentry.startSpan(
163
- {
164
- name: 'rpc request',
165
- op: 'rpc.server',
166
- attributes: {
167
- ip,
168
- transport: 'http',
169
- ua: req.headers.get('user-agent') ?? '',
170
- origin: req.headers.get('origin') ?? ''
171
- }
172
- },
173
- async () => {
174
- try {
175
- let beforeSends: Array<() => Promise<any>> = [];
176
- let id = generateCustomId('req_');
177
-
178
- let parseCookies = memo(() =>
179
- Cookie.parse(req.headers.get('cookie') ?? '')
180
- );
181
-
182
- let request: ServiceRequest = {
183
- url: req.url,
184
- headers: req.headers,
185
- query: url.searchParams,
186
- body,
187
- rawBody: body,
195
+ let extractedTraceContext = propagation.extract(otelContext.active(), req.headers, {
196
+ get: (carrier, key) => carrier.get(key) ?? undefined,
197
+ keys: carrier => Array.from(carrier.keys())
198
+ });
199
+
200
+ let incomingParent = trace.getSpanContext(extractedTraceContext);
201
+ let canTrace = isTelemetryEnabled() && (!!incomingParent || !!opts.allowRootSpan);
202
+ let requestSpanTarget = summarizeRpcTarget(url, body);
203
+ let requestSpanName = `rpc request: ${requestSpanTarget}`;
204
+ let requestSpanOp = 'rpc.server.request';
205
+
206
+ let executeRequest = async () =>
207
+ await Sentry.withIsolationScope(
208
+ async () =>
209
+ await Sentry.continueTrace(
210
+ { sentryTrace, baggage },
211
+ async () =>
212
+ await Sentry.startSpan(
213
+ {
214
+ name: requestSpanName,
215
+ op: requestSpanOp,
216
+ attributes: {
217
+ 'sentry.op': requestSpanOp,
218
+ 'rpc.request.target': requestSpanTarget,
219
+ 'rpc.description': requestSpanName,
220
+ 'sentry.description': requestSpanName,
188
221
  ip,
189
- requestId: id,
190
-
191
- getCookies: () => parseCookies(),
192
- getCookie: (name: string) => parseCookies()[name],
193
- setCookie: (name: string, value: string, opts?: any) => {
194
- let cookie = Cookie.serialize(name, value, opts);
195
- // @ts-ignore
196
- headers.append('Set-Cookie', cookie);
197
- },
198
-
199
- beforeSend: (handler: () => Promise<any>) => {
200
- beforeSends.push(handler);
201
- },
202
-
203
- sharedMiddlewareMemo: new Map<string, Promise<any>>(),
204
-
205
- appendHeaders: (newHeaders: Record<string, string | string[]>) => {
206
- for (let [key, value] of Object.entries(newHeaders)) {
207
- if (Array.isArray(value)) {
208
- for (let v of value) headers.append(key, v);
209
- } else {
210
- headers.append(key, value);
222
+ transport: 'http',
223
+ ua: req.headers.get('user-agent') ?? '',
224
+ origin: req.headers.get('origin') ?? ''
225
+ }
226
+ },
227
+ async () => {
228
+ try {
229
+ let beforeSends: Array<() => Promise<any>> = [];
230
+ let id = generateCustomId('req_');
231
+
232
+ let parseCookies = memo(() =>
233
+ Cookie.parse(req.headers.get('cookie') ?? '')
234
+ );
235
+
236
+ let request: ServiceRequest = {
237
+ url: req.url,
238
+ headers: req.headers,
239
+ query: url.searchParams,
240
+ body,
241
+ rawBody: body,
242
+ ip,
243
+ requestId: id,
244
+
245
+ getCookies: () => parseCookies(),
246
+ getCookie: (name: string) => parseCookies()[name],
247
+ setCookie: (name: string, value: string, opts?: any) => {
248
+ let cookie = Cookie.serialize(name, value, opts);
249
+ // @ts-ignore
250
+ headers.append('Set-Cookie', cookie);
251
+ },
252
+
253
+ beforeSend: (handler: () => Promise<any>) => {
254
+ beforeSends.push(handler);
255
+ },
256
+
257
+ sharedMiddlewareMemo: new Map<string, Promise<any>>(),
258
+
259
+ appendHeaders: (newHeaders: Record<string, string | string[]>) => {
260
+ for (let [key, value] of Object.entries(newHeaders)) {
261
+ if (Array.isArray(value)) {
262
+ for (let v of value) headers.append(key, v);
263
+ } else {
264
+ headers.append(key, value);
265
+ }
211
266
  }
212
267
  }
213
- }
214
- };
215
-
216
- Sentry.getCurrentScope().setContext('rpc.request', {
217
- url: req.url,
218
- query: Object.fromEntries(url.searchParams.entries())
219
- });
220
-
221
- Sentry.getCurrentScope().addAttachment({
222
- filename: 'rpc.request.body.json',
223
- data: body,
224
- contentType: 'application/json'
225
- });
226
-
227
- return provideExecutionContext(
228
- createExecutionContext({
229
- type: 'request',
230
- contextId: id,
231
- ip: ip ?? '0.0.0.0',
232
- userAgent: req.headers.get('user-agent') ?? ''
233
- }),
234
- async () => {
235
- let callsByRpc = new Map<
236
- number,
237
- { id: string; name: string; payload: any }[]
238
- >();
239
-
240
- let resRef = {
241
- body: {
242
- __typename: 'rpc.response',
243
- calls: [] as any[]
244
- },
245
- status: 200
246
- };
247
-
248
- let pathParts = url.pathname.split('/').filter(Boolean);
249
- let lastPart = pathParts[pathParts.length - 1];
250
-
251
- let isSingle = lastPart[0] == '$';
252
-
253
- if (isSingle) {
254
- let id = lastPart.slice(1);
255
- let rpcIndex = handlerNameToRpcMap.get(id);
256
- if (rpcIndex == undefined) {
257
- return new Response(
258
- JSON.stringify(
259
- notFoundError({ entity: 'handler' }).toResponse()
260
- ),
261
- { status: 404, headers }
262
- );
263
- }
264
-
265
- let calls = callsByRpc.get(rpcIndex) ?? [];
266
- calls.push({
267
- id: generateCustomId('call_'),
268
- name: id,
269
- payload: body
270
- });
271
- callsByRpc.set(rpcIndex, calls);
272
- } else {
273
- let valRes = validation.validate(body);
274
- if (!valRes.success) {
275
- return new Response(
276
- JSON.stringify(
277
- validationError({
278
- errors: valRes.errors,
279
- entity: 'request_data'
280
- }).toResponse()
281
- ),
282
- { status: 406, headers }
283
- );
284
- }
285
-
286
- for (let call of valRes.value.calls) {
287
- let rpcIndex = handlerNameToRpcMap.get(call.name);
268
+ };
269
+
270
+ Sentry.getCurrentScope().setContext('rpc.request', {
271
+ url: req.url,
272
+ query: Object.fromEntries(url.searchParams.entries())
273
+ });
274
+
275
+ Sentry.getCurrentScope().addAttachment({
276
+ filename: 'rpc.request.body.json',
277
+ data: body,
278
+ contentType: 'application/json'
279
+ });
280
+
281
+ return provideExecutionContext(
282
+ createExecutionContext({
283
+ type: 'request',
284
+ contextId: id,
285
+ ip: ip ?? '0.0.0.0',
286
+ userAgent: req.headers.get('user-agent') ?? ''
287
+ }),
288
+ async () => {
289
+ let callsByRpc = new Map<
290
+ number,
291
+ { id: string; name: string; payload: any }[]
292
+ >();
293
+
294
+ let resRef = {
295
+ body: {
296
+ __typename: 'rpc.response',
297
+ calls: [] as any[]
298
+ },
299
+ status: 200
300
+ };
301
+
302
+ let pathParts = url.pathname.split('/').filter(Boolean);
303
+ let lastPart = pathParts[pathParts.length - 1];
304
+
305
+ let isSingle = lastPart[0] == '$';
306
+
307
+ if (isSingle) {
308
+ let id = lastPart.slice(1);
309
+ let rpcIndex = handlerNameToRpcMap.get(id);
288
310
  if (rpcIndex == undefined) {
289
311
  return new Response(
290
312
  JSON.stringify(
@@ -295,66 +317,150 @@ export let rpcMux = (
295
317
  }
296
318
 
297
319
  let calls = callsByRpc.get(rpcIndex) ?? [];
298
- calls.push(call as any);
299
- callsByRpc.set(rpcIndex, calls);
300
- }
301
- }
302
-
303
- await Promise.all(
304
- Array.from(callsByRpc.entries()).map(async ([rpcIndex, calls]) => {
305
- let rpc = rpcs[rpcIndex];
306
- let res = await rpc.runMany(request, {
307
- requestId: id,
308
- calls
320
+ calls.push({
321
+ id: generateCustomId('call_'),
322
+ name: id,
323
+ payload: body
309
324
  });
325
+ callsByRpc.set(rpcIndex, calls);
326
+ } else {
327
+ let valRes = validation.validate(body);
328
+ if (!valRes.success) {
329
+ return new Response(
330
+ JSON.stringify(
331
+ validationError({
332
+ errors: valRes.errors,
333
+ entity: 'request_data'
334
+ }).toResponse()
335
+ ),
336
+ { status: 406, headers }
337
+ );
338
+ }
310
339
 
311
- resRef.status = Math.max(resRef.status, res.status);
312
- resRef.body.calls.push(...res.body.calls);
313
- })
314
- );
340
+ for (let call of valRes.value.calls) {
341
+ let rpcIndex = handlerNameToRpcMap.get(call.name);
342
+ if (rpcIndex == undefined) {
343
+ return new Response(
344
+ JSON.stringify(
345
+ notFoundError({ entity: 'handler' }).toResponse()
346
+ ),
347
+ { status: 404, headers }
348
+ );
349
+ }
350
+
351
+ let calls = callsByRpc.get(rpcIndex) ?? [];
352
+ calls.push(call as any);
353
+ callsByRpc.set(rpcIndex, calls);
354
+ }
355
+ }
315
356
 
316
- headers.append('x-req-id', id);
317
- headers.append('content-type', 'application/rpc+json');
318
- headers.append('x-powered-by', 'lowerdeck RPC');
357
+ await Promise.all(
358
+ Array.from(callsByRpc.entries()).map(async ([rpcIndex, calls]) => {
359
+ let rpc = rpcs[rpcIndex];
360
+ let res = await rpc.runMany(request, {
361
+ requestId: id,
362
+ calls
363
+ });
364
+
365
+ resRef.status = Math.max(resRef.status, res.status);
366
+ resRef.body.calls.push(...res.body.calls);
367
+ })
368
+ );
369
+
370
+ headers.append('x-req-id', id);
371
+ headers.append('content-type', 'application/rpc+json');
372
+ headers.append('x-powered-by', 'lowerdeck RPC');
373
+
374
+ await Promise.all(beforeSends.map(s => s()));
375
+
376
+ return new Response(
377
+ serialize.encode(
378
+ isSingle ? resRef.body.calls[0].result : resRef.body
379
+ ),
380
+ {
381
+ status: resRef.status,
382
+ headers
383
+ }
384
+ );
385
+ }
386
+ );
387
+ } catch (e) {
388
+ if (verbose) console.error(e);
389
+
390
+ Sentry.captureException(e, {
391
+ extra: { url: req.url, method: req.method, ip, body }
392
+ });
393
+
394
+ return new Response(
395
+ JSON.stringify(
396
+ internalServerError({
397
+ inner: verbose
398
+ ? e instanceof Error
399
+ ? { message: e.message, stack: e.stack }
400
+ : { error: e }
401
+ : undefined
402
+ }).toResponse()
403
+ ),
404
+ {
405
+ status: 500,
406
+ headers
407
+ }
408
+ );
409
+ }
410
+ }
411
+ )
412
+ )
413
+ );
319
414
 
320
- await Promise.all(beforeSends.map(s => s()));
415
+ return await otelContext.with(extractedTraceContext, async () => {
416
+ if (!canTrace) {
417
+ return await executeRequest();
418
+ }
321
419
 
322
- return new Response(
323
- serialize.encode(
324
- isSingle ? resRef.body.calls[0].result : resRef.body
325
- ),
326
- {
327
- status: resRef.status,
328
- headers
329
- }
330
- );
331
- }
332
- );
333
- } catch (e) {
334
- if (verbose) console.error(e);
335
-
336
- Sentry.captureException(e);
337
-
338
- return new Response(
339
- JSON.stringify(
340
- internalServerError({
341
- inner: verbose
342
- ? e instanceof Error
343
- ? { message: e.message, stack: e.stack }
344
- : { error: e }
345
- : undefined
346
- }).toResponse()
347
- ),
348
- {
349
- status: 500,
350
- headers
351
- }
352
- );
353
- }
354
- }
355
- )
356
- )
357
- );
420
+ return await tracer.startActiveSpan(
421
+ requestSpanName,
422
+ {
423
+ kind: SpanKind.SERVER,
424
+ attributes: {
425
+ 'sentry.op': requestSpanOp,
426
+ 'rpc.system': 'lowerdeck',
427
+ 'rpc.request.target': requestSpanTarget,
428
+ 'rpc.description': requestSpanName,
429
+ 'sentry.description': requestSpanName,
430
+ 'http.request.method': req.method,
431
+ 'url.path': url.pathname,
432
+ ip: ip ?? '',
433
+ transport: 'http',
434
+ ua: req.headers.get('user-agent') ?? '',
435
+ origin: req.headers.get('origin') ?? ''
436
+ }
437
+ },
438
+ async span => {
439
+ try {
440
+ let response = await executeRequest();
441
+
442
+ span.setAttribute('http.response.status_code', response.status);
443
+ if (response.status >= 500) {
444
+ span.setStatus({
445
+ code: SpanStatusCode.ERROR,
446
+ message: `RPC request failed with status ${response.status}`
447
+ });
448
+ }
449
+
450
+ return response;
451
+ } catch (error) {
452
+ span.recordException(error as Error);
453
+ span.setStatus({
454
+ code: SpanStatusCode.ERROR,
455
+ message: error instanceof Error ? error.message : String(error)
456
+ });
457
+ throw error;
458
+ } finally {
459
+ span.end();
460
+ }
461
+ }
462
+ );
463
+ });
358
464
  }
359
465
  };
360
466
  };
package/src/server.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import { internalServerError, isServiceError, notFoundError } from '@lowerdeck/error';
2
2
  import { getSentry } from '@lowerdeck/sentry';
3
+ import {
4
+ hasActiveSpan,
5
+ isTelemetryEnabled,
6
+ SpanStatusCode,
7
+ trace
8
+ } from '@lowerdeck/telemetry';
3
9
  import { Controller, Handler, ServiceRequest } from './controller';
4
10
 
5
11
  let Sentry = getSentry();
12
+ let tracer = trace.getTracer('lowerdeck.rpc-server.calls');
13
+
14
+ let verbose = process.env.NODE_ENV !== 'production';
6
15
 
7
16
  export let createServer =
8
17
  (opts: {
@@ -57,59 +66,121 @@ export let createServer =
57
66
  }> => {
58
67
  let request = { ...req, body: call.payload };
59
68
 
60
- try {
61
- let handler = findHandler(call.name);
69
+ let executeCall = async () => {
70
+ try {
71
+ let handler = findHandler(call.name);
72
+
73
+ if (!handler) {
74
+ return {
75
+ request,
76
+ status: 404,
77
+ response: notFoundError({ entity: 'handler' }).toResponse()
78
+ };
79
+ }
80
+
81
+ let response = await handler.run(request, {});
62
82
 
63
- if (!handler) {
64
83
  return {
65
- request,
66
- status: 404,
67
- response: notFoundError({ entity: 'handler' }).toResponse()
84
+ status: 200,
85
+ request: req,
86
+ response: response.response
68
87
  };
88
+ } catch (e) {
89
+ if (verbose) console.error(e);
90
+
91
+ if (isServiceError(e)) {
92
+ if (e.data.status >= 500) {
93
+ Sentry.captureException(e, {
94
+ tags: { reqId }
95
+ });
96
+ }
97
+
98
+ return {
99
+ request,
100
+ status: e.data.status,
101
+ response: e.toResponse()
102
+ };
103
+ }
104
+
105
+ Sentry.captureException(e, {
106
+ tags: { reqId }
107
+ });
108
+
109
+ opts.onError?.({
110
+ callName: call.name,
111
+ callId: call.id,
112
+ request: req,
113
+ error: e,
114
+ reqId
115
+ });
116
+
117
+ throw e;
69
118
  }
119
+ };
70
120
 
71
- let response = await handler.run(request, {});
121
+ let canTrace = isTelemetryEnabled() && hasActiveSpan();
122
+ if (!canTrace) {
123
+ let result = await executeCall();
72
124
 
73
125
  return {
74
- status: 200,
75
- request: req,
76
- response: response.response
126
+ request: result.request,
127
+ status: result.status,
128
+ response: result.response
77
129
  };
78
- } catch (e) {
79
- console.error(e);
130
+ }
80
131
 
81
- if (isServiceError(e)) {
82
- if (e.data.status >= 500) {
83
- Sentry.captureException(e, {
84
- tags: { reqId }
132
+ let callSpanName = `rpc call: ${call.name}`;
133
+ let callSpanOp = 'rpc.server.call';
134
+
135
+ return await tracer.startActiveSpan(callSpanName, async span => {
136
+ span.setAttribute('sentry.op', callSpanOp);
137
+ span.setAttribute('rpc.system', 'lowerdeck');
138
+ span.setAttribute('rpc.method', call.name);
139
+ span.setAttribute('rpc.request_id', reqId);
140
+ span.setAttribute('rpc.call_id', call.id);
141
+ span.setAttribute('rpc.description', callSpanName);
142
+ span.setAttribute('sentry.description', callSpanName);
143
+
144
+ let finalize = (result: {
145
+ request: ServiceRequest;
146
+ status: number;
147
+ response: any;
148
+ }) => {
149
+ span.setAttribute('rpc.response.status_code', result.status);
150
+ if (result.status >= 500) {
151
+ span.setStatus({
152
+ code: SpanStatusCode.ERROR,
153
+ message: `RPC call failed with status ${result.status}`
85
154
  });
86
155
  }
87
156
 
88
- return {
89
- request,
90
- status: e.data.status,
91
- response: e.toResponse()
92
- };
93
- }
157
+ return result;
158
+ };
94
159
 
95
- Sentry.captureException(e, {
96
- tags: { reqId }
97
- });
160
+ try {
161
+ let result = await executeCall();
98
162
 
99
- opts.onError?.({
100
- callName: call.name,
101
- callId: call.id,
102
- request: req,
103
- error: e,
104
- reqId
105
- });
163
+ return finalize({
164
+ request: result.request,
165
+ status: result.status,
166
+ response: result.response
167
+ });
168
+ } catch (e) {
169
+ span.recordException(e as Error);
170
+ span.setStatus({
171
+ code: SpanStatusCode.ERROR,
172
+ message: e instanceof Error ? e.message : String(e)
173
+ });
106
174
 
107
- return {
108
- request,
109
- status: 500,
110
- response: internalServerError().toResponse()
111
- };
112
- }
175
+ return finalize({
176
+ request,
177
+ status: 500,
178
+ response: internalServerError().toResponse()
179
+ });
180
+ } finally {
181
+ span.end();
182
+ }
183
+ });
113
184
  };
114
185
 
115
186
  let runMany = async (
@@ -140,7 +211,7 @@ export let createServer =
140
211
  });
141
212
  } catch (e) {
142
213
  Sentry.captureException(e);
143
- console.error(e);
214
+ if (verbose) console.error(e);
144
215
  }
145
216
 
146
217
  return {