autotel-tanstack 1.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 (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +294 -0
  3. package/dist/auto.d.ts +44 -0
  4. package/dist/auto.js +47 -0
  5. package/dist/auto.js.map +1 -0
  6. package/dist/browser/context.d.ts +48 -0
  7. package/dist/browser/context.js +3 -0
  8. package/dist/browser/context.js.map +1 -0
  9. package/dist/browser/debug-headers.d.ts +16 -0
  10. package/dist/browser/debug-headers.js +3 -0
  11. package/dist/browser/debug-headers.js.map +1 -0
  12. package/dist/browser/error-reporting.d.ts +37 -0
  13. package/dist/browser/error-reporting.js +3 -0
  14. package/dist/browser/error-reporting.js.map +1 -0
  15. package/dist/browser/handlers.d.ts +19 -0
  16. package/dist/browser/handlers.js +3 -0
  17. package/dist/browser/handlers.js.map +1 -0
  18. package/dist/browser/index.d.ts +10 -0
  19. package/dist/browser/index.js +12 -0
  20. package/dist/browser/index.js.map +1 -0
  21. package/dist/browser/loaders.d.ts +36 -0
  22. package/dist/browser/loaders.js +3 -0
  23. package/dist/browser/loaders.js.map +1 -0
  24. package/dist/browser/metrics.d.ts +54 -0
  25. package/dist/browser/metrics.js +3 -0
  26. package/dist/browser/metrics.js.map +1 -0
  27. package/dist/browser/middleware.d.ts +39 -0
  28. package/dist/browser/middleware.js +3 -0
  29. package/dist/browser/middleware.js.map +1 -0
  30. package/dist/browser/server-functions.d.ts +19 -0
  31. package/dist/browser/server-functions.js +3 -0
  32. package/dist/browser/server-functions.js.map +1 -0
  33. package/dist/browser/testing.d.ts +45 -0
  34. package/dist/browser/testing.js +3 -0
  35. package/dist/browser/testing.js.map +1 -0
  36. package/dist/browser/types.d.ts +85 -0
  37. package/dist/browser/types.js +3 -0
  38. package/dist/browser/types.js.map +1 -0
  39. package/dist/chunk-4C7T5ZIM.js +20 -0
  40. package/dist/chunk-4C7T5ZIM.js.map +1 -0
  41. package/dist/chunk-CSFIPJC2.js +11 -0
  42. package/dist/chunk-CSFIPJC2.js.map +1 -0
  43. package/dist/chunk-DTZCOB4W.js +32 -0
  44. package/dist/chunk-DTZCOB4W.js.map +1 -0
  45. package/dist/chunk-EGRHWZRV.js +3 -0
  46. package/dist/chunk-EGRHWZRV.js.map +1 -0
  47. package/dist/chunk-EUYFVNYE.js +16 -0
  48. package/dist/chunk-EUYFVNYE.js.map +1 -0
  49. package/dist/chunk-HIQYW2HB.js +20 -0
  50. package/dist/chunk-HIQYW2HB.js.map +1 -0
  51. package/dist/chunk-HKM7LMO6.js +129 -0
  52. package/dist/chunk-HKM7LMO6.js.map +1 -0
  53. package/dist/chunk-I4LX3LOG.js +35 -0
  54. package/dist/chunk-I4LX3LOG.js.map +1 -0
  55. package/dist/chunk-JSI6QG7M.js +96 -0
  56. package/dist/chunk-JSI6QG7M.js.map +1 -0
  57. package/dist/chunk-JXO7H6KO.js +10 -0
  58. package/dist/chunk-JXO7H6KO.js.map +1 -0
  59. package/dist/chunk-MFYOV2SF.js +32 -0
  60. package/dist/chunk-MFYOV2SF.js.map +1 -0
  61. package/dist/chunk-MNP65ZX7.js +21 -0
  62. package/dist/chunk-MNP65ZX7.js.map +1 -0
  63. package/dist/chunk-NTY64BKS.js +38 -0
  64. package/dist/chunk-NTY64BKS.js.map +1 -0
  65. package/dist/chunk-OLBHLVLE.js +220 -0
  66. package/dist/chunk-OLBHLVLE.js.map +1 -0
  67. package/dist/chunk-TNOQTZ3N.js +92 -0
  68. package/dist/chunk-TNOQTZ3N.js.map +1 -0
  69. package/dist/chunk-UMEJU65Q.js +34 -0
  70. package/dist/chunk-UMEJU65Q.js.map +1 -0
  71. package/dist/chunk-UTPW3QRT.js +52 -0
  72. package/dist/chunk-UTPW3QRT.js.map +1 -0
  73. package/dist/chunk-V3RO5N2M.js +8 -0
  74. package/dist/chunk-V3RO5N2M.js.map +1 -0
  75. package/dist/chunk-XXBHZR3M.js +99 -0
  76. package/dist/chunk-XXBHZR3M.js.map +1 -0
  77. package/dist/chunk-Z3MJ3GZ6.js +18 -0
  78. package/dist/chunk-Z3MJ3GZ6.js.map +1 -0
  79. package/dist/chunk-Z5D2V4DU.js +216 -0
  80. package/dist/chunk-Z5D2V4DU.js.map +1 -0
  81. package/dist/context.d.ts +94 -0
  82. package/dist/context.js +4 -0
  83. package/dist/context.js.map +1 -0
  84. package/dist/debug-headers.d.ts +43 -0
  85. package/dist/debug-headers.js +5 -0
  86. package/dist/debug-headers.js.map +1 -0
  87. package/dist/error-reporting.d.ts +118 -0
  88. package/dist/error-reporting.js +4 -0
  89. package/dist/error-reporting.js.map +1 -0
  90. package/dist/handlers.d.ts +70 -0
  91. package/dist/handlers.js +6 -0
  92. package/dist/handlers.js.map +1 -0
  93. package/dist/index.d.ts +34 -0
  94. package/dist/index.js +14 -0
  95. package/dist/index.js.map +1 -0
  96. package/dist/loaders.d.ts +124 -0
  97. package/dist/loaders.js +6 -0
  98. package/dist/loaders.js.map +1 -0
  99. package/dist/metrics.d.ts +113 -0
  100. package/dist/metrics.js +4 -0
  101. package/dist/metrics.js.map +1 -0
  102. package/dist/middleware.d.ts +104 -0
  103. package/dist/middleware.js +7 -0
  104. package/dist/middleware.js.map +1 -0
  105. package/dist/server-functions.d.ts +71 -0
  106. package/dist/server-functions.js +6 -0
  107. package/dist/server-functions.js.map +1 -0
  108. package/dist/testing.d.ts +128 -0
  109. package/dist/testing.js +110 -0
  110. package/dist/testing.js.map +1 -0
  111. package/dist/types-C37KSxMN.d.ts +152 -0
  112. package/package.json +166 -0
  113. package/src/auto.ts +86 -0
  114. package/src/browser/context.ts +88 -0
  115. package/src/browser/debug-headers.ts +19 -0
  116. package/src/browser/error-reporting.ts +63 -0
  117. package/src/browser/handlers.ts +23 -0
  118. package/src/browser/index.ts +65 -0
  119. package/src/browser/loaders.ts +62 -0
  120. package/src/browser/metrics.ts +86 -0
  121. package/src/browser/middleware.ts +61 -0
  122. package/src/browser/server-functions.ts +31 -0
  123. package/src/browser/testing.ts +67 -0
  124. package/src/browser/types.ts +100 -0
  125. package/src/context.test.ts +90 -0
  126. package/src/context.ts +145 -0
  127. package/src/debug-headers.ts +109 -0
  128. package/src/env.ts +56 -0
  129. package/src/error-reporting.ts +204 -0
  130. package/src/handlers.ts +339 -0
  131. package/src/index.ts +92 -0
  132. package/src/loaders.test.ts +123 -0
  133. package/src/loaders.ts +267 -0
  134. package/src/metrics.ts +183 -0
  135. package/src/middleware.test.ts +191 -0
  136. package/src/middleware.ts +400 -0
  137. package/src/server-functions.test.ts +86 -0
  138. package/src/server-functions.ts +184 -0
  139. package/src/testing.test.ts +72 -0
  140. package/src/testing.ts +276 -0
  141. package/src/types.test.ts +46 -0
  142. package/src/types.ts +182 -0
@@ -0,0 +1,400 @@
1
+ import { context, SpanStatusCode, type Attributes } from '@opentelemetry/api';
2
+ import { trace, type TraceContext } from 'autotel';
3
+ import { extractContextFromRequest } from './context';
4
+ import { isServerSide } from './env';
5
+ import {
6
+ type TracingMiddlewareConfig,
7
+ DEFAULT_CONFIG,
8
+ SPAN_ATTRIBUTES,
9
+ } from './types';
10
+
11
+ /**
12
+ * Check if a path should be excluded from tracing
13
+ */
14
+ function shouldExcludePath(
15
+ pathname: string,
16
+ excludePaths: (string | RegExp)[],
17
+ ): boolean {
18
+ for (const pattern of excludePaths) {
19
+ if (typeof pattern === 'string') {
20
+ // Simple glob matching
21
+ if (pattern.includes('*')) {
22
+ const regex = new RegExp(
23
+ '^' + pattern.replaceAll('*', '.*').replaceAll('?', '.') + '$',
24
+ );
25
+ if (regex.test(pathname)) return true;
26
+ } else {
27
+ if (pathname === pattern || pathname.startsWith(pattern)) return true;
28
+ }
29
+ } else {
30
+ if (pattern.test(pathname)) return true;
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * Build span attributes for HTTP requests
38
+ */
39
+ function buildRequestAttributes(
40
+ request: Request,
41
+ config: Required<
42
+ Omit<TracingMiddlewareConfig, 'customAttributes' | 'service' | 'type'>
43
+ >,
44
+ ): Attributes {
45
+ const url = new URL(request.url);
46
+ const attrs: Attributes = {
47
+ [SPAN_ATTRIBUTES.HTTP_REQUEST_METHOD]: request.method,
48
+ [SPAN_ATTRIBUTES.URL_PATH]: url.pathname,
49
+ [SPAN_ATTRIBUTES.TANSTACK_TYPE]: 'request',
50
+ };
51
+
52
+ if (url.search) {
53
+ attrs[SPAN_ATTRIBUTES.URL_QUERY] = url.search;
54
+ }
55
+
56
+ // Capture configured headers
57
+ if (config.captureHeaders) {
58
+ for (const header of config.captureHeaders) {
59
+ const value = request.headers.get(header);
60
+ if (value) {
61
+ attrs[`http.request.header.${header.toLowerCase()}`] = value;
62
+ }
63
+ }
64
+ }
65
+
66
+ return attrs;
67
+ }
68
+
69
+ /**
70
+ * Build span attributes for server functions
71
+ */
72
+ function buildServerFnAttributes(
73
+ functionName: string,
74
+ method: string,
75
+ args: unknown,
76
+ config: Required<
77
+ Omit<TracingMiddlewareConfig, 'customAttributes' | 'service' | 'type'>
78
+ >,
79
+ ): Attributes {
80
+ const attrs: Attributes = {
81
+ [SPAN_ATTRIBUTES.RPC_SYSTEM]: 'tanstack-start',
82
+ [SPAN_ATTRIBUTES.RPC_METHOD]: functionName,
83
+ [SPAN_ATTRIBUTES.TANSTACK_TYPE]: 'serverFn',
84
+ [SPAN_ATTRIBUTES.TANSTACK_SERVER_FN_NAME]: functionName,
85
+ [SPAN_ATTRIBUTES.TANSTACK_SERVER_FN_METHOD]: method,
86
+ };
87
+
88
+ if (config.captureArgs && args !== undefined) {
89
+ try {
90
+ attrs[SPAN_ATTRIBUTES.TANSTACK_SERVER_FN_ARGS] = JSON.stringify(args);
91
+ } catch {
92
+ attrs[SPAN_ATTRIBUTES.TANSTACK_SERVER_FN_ARGS] = '[non-serializable]';
93
+ }
94
+ }
95
+
96
+ return attrs;
97
+ }
98
+
99
+ /**
100
+ * Generic middleware handler type (compatible with TanStack's middleware pattern)
101
+ *
102
+ * This type represents the shape of TanStack middleware handlers.
103
+ * We use a generic type to avoid direct dependency on TanStack packages.
104
+ */
105
+ export interface MiddlewareHandler<TContext = unknown> {
106
+ (opts: {
107
+ next: (ctx?: Partial<TContext>) => Promise<TContext>;
108
+ context: TContext;
109
+ request?: Request;
110
+ pathname?: string;
111
+ data?: unknown;
112
+ method?: string;
113
+ filename?: string;
114
+ functionId?: string;
115
+ signal?: AbortSignal;
116
+ }): Promise<TContext>;
117
+ }
118
+
119
+ /**
120
+ * Create a TanStack-compatible tracing middleware
121
+ *
122
+ * This creates middleware that automatically traces all requests/server functions
123
+ * with OpenTelemetry spans. Use with TanStack Start's middleware system.
124
+ *
125
+ * @param config - Configuration options
126
+ * @returns Middleware handler compatible with TanStack Start
127
+ *
128
+ * @example
129
+ * ```typescript
130
+ * // Global request middleware in app/start.ts
131
+ * import { createStart } from '@tanstack/react-start';
132
+ * import { createTracingMiddleware } from 'autotel-tanstack/middleware';
133
+ *
134
+ * export const startInstance = createStart(() => ({
135
+ * requestMiddleware: [
136
+ * createTracingMiddleware({
137
+ * captureHeaders: ['x-request-id', 'user-agent'],
138
+ * excludePaths: ['/health', '/metrics'],
139
+ * }),
140
+ * ],
141
+ * }));
142
+ * ```
143
+ *
144
+ * @example
145
+ * ```typescript
146
+ * // Server function middleware
147
+ * import { createServerFn } from '@tanstack/react-start';
148
+ * import { createTracingMiddleware } from 'autotel-tanstack/middleware';
149
+ *
150
+ * export const getUser = createServerFn({ method: 'GET' })
151
+ * .middleware([createTracingMiddleware({ type: 'function' })])
152
+ * .handler(async ({ data: id }) => {
153
+ * return await db.users.findUnique({ where: { id } });
154
+ * });
155
+ * ```
156
+ */
157
+ export function createTracingMiddleware<TContext = unknown>(
158
+ config?: TracingMiddlewareConfig,
159
+ ): MiddlewareHandler<TContext> {
160
+ const mergedConfig = {
161
+ ...DEFAULT_CONFIG,
162
+ ...config,
163
+ type: config?.type ?? 'request',
164
+ };
165
+
166
+ return async function tracingMiddleware(opts) {
167
+ // If we're in the browser, return a no-op middleware
168
+ // This prevents autotel (which uses Node.js APIs) from being bundled/executed in the browser
169
+ if (!isServerSide()) {
170
+ return opts.next();
171
+ }
172
+ const { next, request, pathname, data, functionId } = opts;
173
+
174
+ // For function middleware
175
+ if (mergedConfig.type === 'function') {
176
+ const fnName = functionId || 'unknown';
177
+ const method = (opts as { method?: string }).method || 'POST';
178
+
179
+ return trace(`tanstack.serverFn.${fnName}`, async (ctx: TraceContext) => {
180
+ const attrs = buildServerFnAttributes(
181
+ fnName,
182
+ method,
183
+ data,
184
+ mergedConfig,
185
+ );
186
+ ctx.setAttributes(attrs as Record<string, string | number | boolean>);
187
+
188
+ // Add custom attributes if provided
189
+ if (config?.customAttributes) {
190
+ const customAttrs = config.customAttributes({
191
+ type: 'serverFn',
192
+ name: fnName,
193
+ args: data,
194
+ });
195
+ ctx.setAttributes(
196
+ customAttrs as Record<string, string | number | boolean>,
197
+ );
198
+ }
199
+
200
+ try {
201
+ const result = await next();
202
+
203
+ // Capture result if configured
204
+ if (mergedConfig.captureResults && result !== undefined) {
205
+ try {
206
+ ctx.setAttribute(
207
+ SPAN_ATTRIBUTES.TANSTACK_SERVER_FN_RESULT,
208
+ JSON.stringify(result),
209
+ );
210
+ } catch {
211
+ ctx.setAttribute(
212
+ SPAN_ATTRIBUTES.TANSTACK_SERVER_FN_RESULT,
213
+ '[non-serializable]',
214
+ );
215
+ }
216
+ }
217
+
218
+ ctx.setStatus({ code: SpanStatusCode.OK });
219
+ return result;
220
+ } catch (error) {
221
+ if (mergedConfig.captureErrors) {
222
+ ctx.recordException(error as Error);
223
+ ctx.setStatus({
224
+ code: SpanStatusCode.ERROR,
225
+ message: (error as Error).message,
226
+ });
227
+
228
+ // Report error to error store
229
+ try {
230
+ const { reportError } = await import('./error-reporting');
231
+ reportError(error as Error, {
232
+ type: 'serverFn',
233
+ name: fnName,
234
+ method,
235
+ });
236
+ } catch {
237
+ // Error reporting not available, skip
238
+ }
239
+ }
240
+ throw error;
241
+ }
242
+ }) as Promise<TContext>;
243
+ }
244
+
245
+ // For request middleware
246
+ if (!request) {
247
+ // No request available, just pass through
248
+ return next();
249
+ }
250
+
251
+ const url = new URL(request.url);
252
+
253
+ // Check if path should be excluded
254
+ if (shouldExcludePath(url.pathname, mergedConfig.excludePaths)) {
255
+ return next();
256
+ }
257
+
258
+ // Extract parent context from request headers
259
+ const parentContext = extractContextFromRequest(request);
260
+
261
+ // Run within parent context for distributed tracing
262
+ return context.with(parentContext, async () => {
263
+ const spanName = `${request.method} ${pathname || url.pathname}`;
264
+
265
+ return trace(spanName, async (ctx: TraceContext) => {
266
+ const attrs = buildRequestAttributes(request, mergedConfig);
267
+ ctx.setAttributes(attrs as Record<string, string | number | boolean>);
268
+
269
+ // Add custom attributes if provided
270
+ if (config?.customAttributes) {
271
+ const customAttrs = config.customAttributes({
272
+ type: 'request',
273
+ name: spanName,
274
+ request,
275
+ });
276
+ ctx.setAttributes(
277
+ customAttrs as Record<string, string | number | boolean>,
278
+ );
279
+ }
280
+
281
+ const startTime = Date.now();
282
+
283
+ try {
284
+ const result = await next();
285
+
286
+ const duration = Date.now() - startTime;
287
+ ctx.setAttribute(
288
+ SPAN_ATTRIBUTES.TANSTACK_REQUEST_DURATION_MS,
289
+ duration,
290
+ );
291
+
292
+ // Record timing in metrics collector
293
+ try {
294
+ const { metricsCollector } = await import('./metrics');
295
+ metricsCollector.recordTiming(spanName, duration);
296
+ } catch {
297
+ // Metrics not available, skip
298
+ }
299
+
300
+ // Try to get response status from result if it's a Response
301
+ if (result && typeof result === 'object' && 'status' in result) {
302
+ ctx.setAttribute(
303
+ SPAN_ATTRIBUTES.HTTP_RESPONSE_STATUS_CODE,
304
+ (result as { status: number }).status,
305
+ );
306
+ }
307
+
308
+ ctx.setStatus({ code: SpanStatusCode.OK });
309
+ return result;
310
+ } catch (error) {
311
+ const duration = Date.now() - startTime;
312
+ ctx.setAttribute(
313
+ SPAN_ATTRIBUTES.TANSTACK_REQUEST_DURATION_MS,
314
+ duration,
315
+ );
316
+
317
+ if (mergedConfig.captureErrors) {
318
+ ctx.recordException(error as Error);
319
+ ctx.setStatus({
320
+ code: SpanStatusCode.ERROR,
321
+ message: (error as Error).message,
322
+ });
323
+
324
+ // Report error to error store
325
+ try {
326
+ const { reportError } = await import('./error-reporting');
327
+ reportError(error as Error, {
328
+ type: 'request',
329
+ method: request.method,
330
+ pathname: url.pathname,
331
+ });
332
+ } catch {
333
+ // Error reporting not available, skip
334
+ }
335
+ }
336
+ throw error;
337
+ }
338
+ }) as Promise<TContext>;
339
+ });
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Pre-configured tracing middleware with sensible defaults
345
+ *
346
+ * Convenience export for quick setup. Uses adaptive sampling,
347
+ * captures x-request-id header, and excludes common health check paths.
348
+ *
349
+ * @param config - Optional configuration overrides
350
+ * @returns Middleware handler
351
+ *
352
+ * @example
353
+ * ```typescript
354
+ * import { createStart } from '@tanstack/react-start';
355
+ * import { tracingMiddleware } from 'autotel-tanstack/middleware';
356
+ *
357
+ * export const startInstance = createStart(() => ({
358
+ * requestMiddleware: [tracingMiddleware()],
359
+ * }));
360
+ * ```
361
+ */
362
+ export function tracingMiddleware<TContext = unknown>(
363
+ config?: TracingMiddlewareConfig,
364
+ ): MiddlewareHandler<TContext> {
365
+ return createTracingMiddleware({
366
+ sampling: 'adaptive',
367
+ captureHeaders: ['x-request-id', 'user-agent'],
368
+ excludePaths: ['/health', '/healthz', '/ready', '/metrics', '/_ping'],
369
+ ...config,
370
+ });
371
+ }
372
+
373
+ /**
374
+ * Create function-specific tracing middleware
375
+ *
376
+ * Convenience wrapper for server function middleware.
377
+ *
378
+ * @param config - Optional configuration
379
+ * @returns Middleware handler for server functions
380
+ *
381
+ * @example
382
+ * ```typescript
383
+ * import { createServerFn } from '@tanstack/react-start';
384
+ * import { functionTracingMiddleware } from 'autotel-tanstack/middleware';
385
+ *
386
+ * export const getUser = createServerFn({ method: 'GET' })
387
+ * .middleware([functionTracingMiddleware()])
388
+ * .handler(async ({ data: id }) => {
389
+ * return await db.users.findUnique({ where: { id } });
390
+ * });
391
+ * ```
392
+ */
393
+ export function functionTracingMiddleware<TContext = unknown>(
394
+ config?: Omit<TracingMiddlewareConfig, 'type'>,
395
+ ): MiddlewareHandler<TContext> {
396
+ return createTracingMiddleware({
397
+ ...config,
398
+ type: 'function',
399
+ });
400
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { traceServerFn, createTracedServerFnFactory } from './server-functions';
3
+
4
+ // Mock autotel
5
+ vi.mock('autotel', () => ({
6
+ trace: vi.fn((name, fn) =>
7
+ fn({
8
+ setAttributes: vi.fn(),
9
+ setAttribute: vi.fn(),
10
+ setStatus: vi.fn(),
11
+ recordException: vi.fn(),
12
+ }),
13
+ ),
14
+ }));
15
+
16
+ describe('server-functions', () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ });
20
+
21
+ describe('traceServerFn', () => {
22
+ it('should wrap a server function', async () => {
23
+ const originalFn = vi.fn().mockResolvedValue({ id: '123', name: 'Test' });
24
+ const tracedFn = traceServerFn(originalFn, { name: 'getUser' });
25
+
26
+ const result = await tracedFn({ id: '123' });
27
+
28
+ expect(originalFn).toHaveBeenCalledWith({ id: '123' });
29
+ expect(result).toEqual({ id: '123', name: 'Test' });
30
+ });
31
+
32
+ it('should use function name if no name provided', async () => {
33
+ async function namedFunction() {
34
+ return 'result';
35
+ }
36
+ const tracedFn = traceServerFn(namedFunction);
37
+
38
+ await tracedFn();
39
+ expect(tracedFn).toBeDefined();
40
+ });
41
+
42
+ it('should propagate errors', async () => {
43
+ const error = new Error('Test error');
44
+ const originalFn = vi.fn().mockRejectedValue(error);
45
+ const tracedFn = traceServerFn(originalFn, { name: 'failingFn' });
46
+
47
+ await expect(tracedFn()).rejects.toThrow('Test error');
48
+ });
49
+
50
+ it('should preserve function properties', () => {
51
+ const originalFn = Object.assign(vi.fn().mockResolvedValue('result'), {
52
+ customProp: 'value',
53
+ });
54
+ const tracedFn = traceServerFn(originalFn, { name: 'testFn' });
55
+
56
+ expect((tracedFn as any).customProp).toBe('value');
57
+ });
58
+ });
59
+
60
+ describe('createTracedServerFnFactory', () => {
61
+ it('should create a factory that wraps createServerFn', () => {
62
+ const mockCreateServerFn = vi.fn(() => ({
63
+ handler: vi.fn((fn) => fn),
64
+ }));
65
+
66
+ const tracedFactory = createTracedServerFnFactory(mockCreateServerFn);
67
+ expect(tracedFactory).toBeDefined();
68
+ });
69
+
70
+ it('should wrap handler method', () => {
71
+ const _handlerFn = vi.fn().mockResolvedValue('result');
72
+ const mockResult = {
73
+ handler: vi.fn((fn) => {
74
+ // Simulate returning a callable
75
+ return async (...args: unknown[]) => fn(...args);
76
+ }),
77
+ };
78
+ const mockCreateServerFn = vi.fn(() => mockResult);
79
+
80
+ const tracedFactory = createTracedServerFnFactory(mockCreateServerFn);
81
+ const builder = tracedFactory({ method: 'GET' });
82
+
83
+ expect(builder.handler).toBeDefined();
84
+ });
85
+ });
86
+ });
@@ -0,0 +1,184 @@
1
+ import { SpanStatusCode } from '@opentelemetry/api';
2
+ import { trace, type TraceContext } from 'autotel';
3
+ import { isServerSide } from './env';
4
+ import { type TraceServerFnConfig, SPAN_ATTRIBUTES } from './types';
5
+
6
+ /**
7
+ * Wrap a TanStack server function with OpenTelemetry tracing
8
+ *
9
+ * This function wraps a server function to automatically create spans
10
+ * for each invocation. It captures function name, arguments (optionally),
11
+ * results (optionally), and errors.
12
+ *
13
+ * @param serverFn - The server function to wrap
14
+ * @param config - Configuration options
15
+ * @returns Wrapped server function with tracing
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import { createServerFn } from '@tanstack/react-start';
20
+ * import { traceServerFn } from 'autotel-tanstack/server-functions';
21
+ *
22
+ * const getUserBase = createServerFn({ method: 'GET' })
23
+ * .handler(async ({ data: id }) => {
24
+ * return await db.users.findUnique({ where: { id } });
25
+ * });
26
+ *
27
+ * export const getUser = traceServerFn(getUserBase, { name: 'getUser' });
28
+ * ```
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * // With argument and result capture (careful with PII!)
33
+ * export const createUser = traceServerFn(
34
+ * createServerFn({ method: 'POST' })
35
+ * .handler(async ({ data }) => {
36
+ * return await db.users.create({ data });
37
+ * }),
38
+ * {
39
+ * name: 'createUser',
40
+ * captureArgs: true,
41
+ * captureResults: false, // Don't capture for PII reasons
42
+ * }
43
+ * );
44
+ * ```
45
+ */
46
+ export function traceServerFn<
47
+ T extends (...args: unknown[]) => Promise<unknown>,
48
+ >(serverFn: T, config: TraceServerFnConfig = {}): T {
49
+ const fnName = config.name || serverFn.name || 'serverFn';
50
+ const captureArgs = config.captureArgs ?? true;
51
+ const captureResults = config.captureResults ?? false;
52
+
53
+ return new Proxy(serverFn, {
54
+ apply(target, thisArg, argArray) {
55
+ // If we're in the browser, just call the function without tracing
56
+ // Server functions should never run in the browser, but this prevents
57
+ // autotel (which uses Node.js APIs) from being executed if it somehow does
58
+ if (!isServerSide()) {
59
+ return target.apply(thisArg, argArray);
60
+ }
61
+
62
+ return trace(`tanstack.serverFn.${fnName}`, async (ctx: TraceContext) => {
63
+ ctx.setAttributes({
64
+ [SPAN_ATTRIBUTES.RPC_SYSTEM]: 'tanstack-start',
65
+ [SPAN_ATTRIBUTES.RPC_METHOD]: fnName,
66
+ [SPAN_ATTRIBUTES.TANSTACK_TYPE]: 'serverFn',
67
+ [SPAN_ATTRIBUTES.TANSTACK_SERVER_FN_NAME]: fnName,
68
+ });
69
+
70
+ // Capture arguments if configured
71
+ if (captureArgs && argArray.length > 0) {
72
+ const args = argArray[0];
73
+ if (args !== undefined) {
74
+ try {
75
+ ctx.setAttribute(
76
+ SPAN_ATTRIBUTES.TANSTACK_SERVER_FN_ARGS,
77
+ JSON.stringify(args),
78
+ );
79
+ } catch {
80
+ ctx.setAttribute(
81
+ SPAN_ATTRIBUTES.TANSTACK_SERVER_FN_ARGS,
82
+ '[non-serializable]',
83
+ );
84
+ }
85
+ }
86
+ }
87
+
88
+ try {
89
+ const result = await Reflect.apply(target, thisArg, argArray);
90
+
91
+ // Capture result if configured
92
+ if (captureResults && result !== undefined) {
93
+ try {
94
+ ctx.setAttribute(
95
+ SPAN_ATTRIBUTES.TANSTACK_SERVER_FN_RESULT,
96
+ JSON.stringify(result),
97
+ );
98
+ } catch {
99
+ ctx.setAttribute(
100
+ SPAN_ATTRIBUTES.TANSTACK_SERVER_FN_RESULT,
101
+ '[non-serializable]',
102
+ );
103
+ }
104
+ }
105
+
106
+ ctx.setStatus({ code: SpanStatusCode.OK });
107
+ return result;
108
+ } catch (error) {
109
+ ctx.recordException(error as Error);
110
+ ctx.setStatus({
111
+ code: SpanStatusCode.ERROR,
112
+ message: (error as Error).message,
113
+ });
114
+ throw error;
115
+ }
116
+ });
117
+ },
118
+
119
+ get(target, prop, receiver) {
120
+ return Reflect.get(target, prop, receiver);
121
+ },
122
+ }) as T;
123
+ }
124
+
125
+ /**
126
+ * Create a traced version of createServerFn
127
+ *
128
+ * This higher-order function wraps TanStack's createServerFn to automatically
129
+ * add tracing to all created server functions.
130
+ *
131
+ * @param createServerFnOriginal - The original createServerFn from TanStack
132
+ * @param defaultConfig - Default configuration for all server functions
133
+ * @returns Wrapped createServerFn that produces traced server functions
134
+ *
135
+ * @example
136
+ * ```typescript
137
+ * import { createServerFn as originalCreateServerFn } from '@tanstack/react-start';
138
+ * import { createTracedServerFnFactory } from 'autotel-tanstack/server-functions';
139
+ *
140
+ * export const createServerFn = createTracedServerFnFactory(originalCreateServerFn);
141
+ *
142
+ * // Now all server functions created with createServerFn are automatically traced
143
+ * export const getUser = createServerFn({ method: 'GET' })
144
+ * .handler(async ({ data: id }) => {
145
+ * return await db.users.findUnique({ where: { id } });
146
+ * });
147
+ * ```
148
+ */
149
+ export function createTracedServerFnFactory<
150
+ TCreateServerFn extends (...args: unknown[]) => unknown,
151
+ >(
152
+ createServerFnOriginal: TCreateServerFn,
153
+ defaultConfig: Omit<TraceServerFnConfig, 'name'> = {},
154
+ ): TCreateServerFn {
155
+ return new Proxy(createServerFnOriginal, {
156
+ apply(target, thisArg, argArray) {
157
+ const result = Reflect.apply(target, thisArg, argArray);
158
+
159
+ // If the result has a .handler method, wrap it
160
+ if (
161
+ result &&
162
+ typeof result === 'object' &&
163
+ 'handler' in result &&
164
+ typeof result.handler === 'function'
165
+ ) {
166
+ const originalHandler = result.handler.bind(result);
167
+
168
+ result.handler = function tracedHandler(handlerFn: unknown) {
169
+ const wrappedHandler = originalHandler(handlerFn as never);
170
+
171
+ // Try to infer function name from the handler
172
+ const fnName = (handlerFn as { name?: string })?.name || 'serverFn';
173
+
174
+ return traceServerFn(wrappedHandler, {
175
+ ...defaultConfig,
176
+ name: fnName,
177
+ });
178
+ };
179
+ }
180
+
181
+ return result;
182
+ },
183
+ }) as TCreateServerFn;
184
+ }