@getvision/adapter-fastify 0.0.0-develop-20251031183955

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@getvision/adapter-fastify",
3
+ "version": "0.0.0-develop-20251031183955",
4
+ "description": "Fastify adapter for Vision Dashboard",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "module": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev": "tsc --watch",
15
+ "clean": "rm -rf dist"
16
+ },
17
+ "keywords": [
18
+ "vision",
19
+ "fastify",
20
+ "observability",
21
+ "tracing",
22
+ "monitoring"
23
+ ],
24
+ "author": "",
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "@fastify/request-context": "^6.2.1",
28
+ "fastify-plugin": "^5.1.0",
29
+ "@getvision/core": "0.0.1"
30
+ },
31
+ "peerDependencies": {
32
+ "fastify": "^5.6.1",
33
+ "zod": "^3.22.4"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "zod": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^20.10.0",
42
+ "fastify": "^5.6.1",
43
+ "typescript": "^5.9.3",
44
+ "zod": "^3.24.4"
45
+ },
46
+ "config": {}
47
+ }
@@ -0,0 +1,12 @@
1
+ import type { VisionCore, Trace } from '@getvision/core';
2
+
3
+ declare module '@fastify/request-context' {
4
+ interface RequestContextData {
5
+ visionTrace: {
6
+ vision: VisionCore;
7
+ trace: Trace;
8
+ traceId: string;
9
+ rootSpanId: string;
10
+ };
11
+ }
12
+ }
package/src/index.ts ADDED
@@ -0,0 +1,489 @@
1
+ import type { FastifyInstance, FastifyPluginAsync } from 'fastify'
2
+ import fp from 'fastify-plugin'
3
+ import {
4
+ VisionCore,
5
+ autoDetectPackageInfo,
6
+ autoDetectIntegrations,
7
+ detectDrizzle,
8
+ startDrizzleStudio,
9
+ stopDrizzleStudio,
10
+ } from '@getvision/core'
11
+ import type {
12
+ RequestBodySchema,
13
+ RouteMetadata,
14
+ SchemaField,
15
+ Trace,
16
+ VisionFastifyOptions,
17
+ ServiceDefinition,
18
+ } from '@getvision/core'
19
+ import { fastifyRequestContext, requestContext } from '@fastify/request-context'
20
+
21
+ interface VisionContext {
22
+ vision: VisionCore
23
+ trace: Trace
24
+ traceId: string
25
+ rootSpanId: string
26
+ }
27
+
28
+ export function getVisionContext(): VisionContext {
29
+ const ctx = requestContext.get('visionTrace')
30
+ if (!ctx) {
31
+ throw new Error('Vision context not available. Make sure visionPlugin is registered.')
32
+ }
33
+ return ctx
34
+ }
35
+
36
+ export function useVisionSpan() {
37
+ const { vision, traceId, rootSpanId } = getVisionContext()
38
+ const tracer = vision.getTracer()
39
+
40
+ return <T>(
41
+ name: string,
42
+ attributes: Record<string, any> = {},
43
+ fn: () => T
44
+ ): T => {
45
+ const span = tracer.startSpan(name, traceId, rootSpanId)
46
+
47
+ for (const [key, value] of Object.entries(attributes)) {
48
+ tracer.setAttribute(span.id, key, value)
49
+ }
50
+
51
+ try {
52
+ const result = fn()
53
+ const completedSpan = tracer.endSpan(span.id)
54
+
55
+ if (completedSpan) {
56
+ vision.getTraceStore().addSpan(traceId, completedSpan)
57
+ }
58
+
59
+ return result
60
+ } catch (error) {
61
+ tracer.setAttribute(span.id, 'error', true)
62
+ tracer.setAttribute(
63
+ span.id,
64
+ 'error.message',
65
+ error instanceof Error ? error.message : String(error)
66
+ )
67
+ const completedSpan = tracer.endSpan(span.id)
68
+
69
+ if (completedSpan) {
70
+ vision.getTraceStore().addSpan(traceId, completedSpan)
71
+ }
72
+
73
+ throw error
74
+ }
75
+ }
76
+ }
77
+
78
+
79
+ let visionInstance: VisionCore | null = null
80
+
81
+ export function getVisionInstance(): VisionCore | null {
82
+ return visionInstance
83
+ }
84
+
85
+ const visionPluginImpl: FastifyPluginAsync<VisionFastifyOptions> = async (fastify, options) => {
86
+ const {
87
+ port = 9500,
88
+ enabled = true,
89
+ maxTraces = 1000,
90
+ maxLogs = 10000,
91
+ logging = true,
92
+ cors = true,
93
+ } = options
94
+
95
+ if (!enabled) {
96
+ return
97
+ }
98
+
99
+ if (!visionInstance) {
100
+ visionInstance = new VisionCore({
101
+ port,
102
+ maxTraces,
103
+ maxLogs,
104
+ })
105
+ }
106
+
107
+ const vision = visionInstance
108
+
109
+ await fastify.register(fastifyRequestContext, {
110
+ hook: 'onRequest',
111
+ })
112
+
113
+ fastify.addHook('onReady', async () => {
114
+ // Auto-detect service info
115
+ const pkgInfo = autoDetectPackageInfo()
116
+ const autoDetectedIntegrations = autoDetectIntegrations()
117
+
118
+ // Merge with user-provided config
119
+ const serviceName = options.service?.name || pkgInfo.name
120
+ const serviceVersion = options.service?.version || pkgInfo.version
121
+ const serviceDesc = options.service?.description
122
+ const integrations = {
123
+ ...autoDetectedIntegrations,
124
+ ...options.service?.integrations,
125
+ }
126
+
127
+ // Filter out undefined values from integrations
128
+ const cleanIntegrations: Record<string, string> = {}
129
+ for (const [key, value] of Object.entries(integrations)) {
130
+ if (value !== undefined) {
131
+ cleanIntegrations[key] = value
132
+ }
133
+ }
134
+
135
+ // Detect and optionally start Drizzle Studio
136
+ const drizzleInfo = detectDrizzle()
137
+ let drizzleStudioUrl: string | undefined
138
+
139
+ if (drizzleInfo.detected) {
140
+ console.log(`🗄️ Drizzle detected (${drizzleInfo.configPath})`)
141
+
142
+ if (options.drizzle?.autoStart) {
143
+ const drizzlePort = options.drizzle.port || 4983
144
+ const started = startDrizzleStudio(drizzlePort)
145
+ if (started) {
146
+ // Drizzle Studio uses local.drizzle.studio domain (with HTTPS)
147
+ drizzleStudioUrl = 'https://local.drizzle.studio'
148
+ }
149
+ } else {
150
+ console.log('💡 Tip: Enable Drizzle Studio auto-start with drizzle: { autoStart: true }')
151
+ drizzleStudioUrl = 'https://local.drizzle.studio'
152
+ }
153
+ }
154
+
155
+ // Set app status with service metadata
156
+ vision.setAppStatus({
157
+ name: serviceName,
158
+ version: serviceVersion,
159
+ description: serviceDesc,
160
+ running: true,
161
+ pid: process.pid,
162
+ metadata: {
163
+ framework: 'Fastify',
164
+ integrations: Object.keys(cleanIntegrations).length > 0 ? cleanIntegrations : undefined,
165
+ drizzle: drizzleInfo.detected
166
+ ? {
167
+ detected: true,
168
+ configPath: drizzleInfo.configPath,
169
+ studioUrl: drizzleStudioUrl,
170
+ autoStarted: options.drizzle?.autoStart || false,
171
+ }
172
+ : undefined,
173
+ },
174
+ })
175
+
176
+ // Cleanup on exit
177
+ process.on('SIGINT', () => {
178
+ stopDrizzleStudio()
179
+ process.exit(0)
180
+ })
181
+
182
+ process.on('SIGTERM', () => {
183
+ stopDrizzleStudio()
184
+ process.exit(0)
185
+ })
186
+ })
187
+
188
+ const CAPTURE_KEY = Symbol.for('vision.fastify.routes')
189
+ const captured: Array<{ method: string; url: string; schema?: any; handlerName?: string }> =
190
+ ((fastify as any)[CAPTURE_KEY] = (fastify as any)[CAPTURE_KEY] || [])
191
+
192
+ fastify.addHook('onRoute', (routeOpts: any) => {
193
+ const methods = Array.isArray(routeOpts.method) ? routeOpts.method : [routeOpts.method]
194
+ for (const m of methods) {
195
+ const method = (m || '').toString().toUpperCase()
196
+ if (!method || method === 'HEAD' || method === 'OPTIONS') continue
197
+ captured.push({
198
+ method,
199
+ url: routeOpts.url as string,
200
+ schema: routeOpts.schema,
201
+ handlerName: routeOpts.handler?.name || 'anonymous',
202
+ })
203
+ }
204
+ })
205
+
206
+ if (cors) {
207
+ fastify.options('/*', async (request, reply) => {
208
+ reply
209
+ .header('Access-Control-Allow-Origin', '*')
210
+ .header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
211
+ .header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Vision-Trace-Id, X-Vision-Session')
212
+ .header('Access-Control-Expose-Headers', 'X-Vision-Trace-Id, X-Vision-Session')
213
+ .code(204)
214
+ .send()
215
+ })
216
+
217
+ fastify.addHook('onRequest', async (request, reply) => {
218
+ reply.header('Access-Control-Allow-Origin', '*')
219
+ reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
220
+ reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Vision-Trace-Id, X-Vision-Session')
221
+ reply.header('Access-Control-Expose-Headers', 'X-Vision-Trace-Id, X-Vision-Session')
222
+ })
223
+ }
224
+
225
+ fastify.addHook('onRequest', async (request, reply) => {
226
+ if (request.method === 'OPTIONS') return
227
+
228
+ const startTime = Date.now();
229
+ (request as any).visionStartTime = startTime
230
+
231
+ const trace = vision.createTrace(request.method, request.url)
232
+
233
+ reply.header('X-Vision-Trace-Id', trace.id)
234
+
235
+ const tracer = vision.getTracer()
236
+ const rootSpan = tracer.startSpan('http.request', trace.id);
237
+
238
+ request.requestContext.set('visionTrace', {
239
+ vision,
240
+ trace,
241
+ traceId: trace.id,
242
+ rootSpanId: rootSpan.id,
243
+ })
244
+
245
+ tracer.setAttribute(rootSpan.id, 'http.method', request.method)
246
+ tracer.setAttribute(rootSpan.id, 'http.path', request.url)
247
+ tracer.setAttribute(rootSpan.id, 'http.url', request.url)
248
+
249
+ if (request.query && Object.keys(request.query).length > 0) {
250
+ tracer.setAttribute(rootSpan.id, 'http.query', request.query)
251
+ }
252
+
253
+ const requestMeta = {
254
+ method: request.method,
255
+ url: request.url,
256
+ headers: request.headers,
257
+ query: Object.keys(request.query || {}).length ? request.query : undefined,
258
+ body: request.body,
259
+ }
260
+ tracer.setAttribute(rootSpan.id, 'http.request', requestMeta)
261
+ trace.metadata = { ...trace.metadata, request: requestMeta }
262
+
263
+ const sessionId = request.headers['x-vision-session']
264
+ if (sessionId) {
265
+ tracer.setAttribute(rootSpan.id, 'session.id', sessionId)
266
+ trace.metadata = { ...trace.metadata, sessionId }
267
+ }
268
+
269
+ if (logging) {
270
+ const parts = [`method=${request.method}`, `path=${request.url}`]
271
+ if (sessionId) parts.push(`sessionId=${sessionId}`)
272
+ parts.push(`traceId=${trace.id}`)
273
+ console.info(`INF starting request ${parts.join(' ')}`)
274
+ }
275
+ })
276
+
277
+ fastify.addHook('onResponse', async (request, reply) => {
278
+ if (request.method === 'OPTIONS') return
279
+
280
+ const startTime = (request as any).visionStartTime
281
+ const context = request.requestContext.get('visionTrace') as VisionContext | undefined
282
+ if (!context || !startTime) return
283
+ const { vision, trace, traceId, rootSpanId } = context
284
+ const tracer = vision.getTracer()
285
+
286
+ try {
287
+ const duration = Date.now() - startTime
288
+ const rootSpan = tracer.getSpan(rootSpanId)
289
+ if (!rootSpan) return
290
+
291
+ tracer.setAttribute(rootSpan.id, 'http.status_code', reply.statusCode)
292
+
293
+ const responseMeta = {
294
+ status: reply.statusCode,
295
+ headers: reply.getHeaders(),
296
+ }
297
+ tracer.setAttribute(rootSpan.id, 'http.response', responseMeta)
298
+ trace.metadata = { ...trace.metadata, response: responseMeta }
299
+
300
+ const completedSpan = tracer.endSpan(rootSpan.id)
301
+ if (completedSpan) {
302
+ vision.getTraceStore().addSpan(traceId, completedSpan)
303
+ }
304
+
305
+ vision.completeTrace(traceId, reply.statusCode, duration)
306
+
307
+ if (logging) {
308
+ console.info(
309
+ `INF request completed code=${reply.statusCode} duration=${duration}ms method=${request.method} path=${request.url} traceId=${traceId}`
310
+ )
311
+ }
312
+ } catch (error) {
313
+ console.error('Vision: Error completing trace:', error)
314
+ }
315
+ })
316
+ }
317
+
318
+ export const visionPlugin = fp(visionPluginImpl, {
319
+ fastify: '5.x',
320
+ name: '@getvision/adapter-fastify'
321
+ })
322
+
323
+ export function enableAutoDiscovery(
324
+ fastify: FastifyInstance,
325
+ options?: { services?: ServiceDefinition[] }
326
+ ): void {
327
+ const vision = visionInstance
328
+ if (!vision) {
329
+ console.warn('Vision not initialized. Call visionPlugin first.')
330
+ return
331
+ }
332
+
333
+ fastify.addHook('onReady', async () => {
334
+ const routes: RouteMetadata[] = []
335
+ const services: Record<string, { name: string; description?: string; routes: RouteMetadata[] }> = {}
336
+
337
+ // Use captured routes from onRoute hook
338
+ const CAPTURE_KEY = Symbol.for('vision.fastify.routes')
339
+ const capturedRoutes = (fastify as any)[CAPTURE_KEY] || []
340
+
341
+ for (const route of capturedRoutes) {
342
+ const routeMeta: RouteMetadata = {
343
+ method: route.method,
344
+ path: route.url,
345
+ handler: route.handlerName || 'anonymous',
346
+ }
347
+
348
+ // Try to get schema from route
349
+ if (route.schema?.body) {
350
+ try {
351
+ routeMeta.requestBody = jsonSchemaToTemplate(route.schema.body)
352
+ } catch (e) {
353
+ // Ignore schema conversion errors
354
+ }
355
+ }
356
+
357
+ // Try to get response schema (Fastify supports response: { 200: { ... } })
358
+ if (route.schema?.response) {
359
+ try {
360
+ // Get the success response schema (200, 201, etc.)
361
+ const responseSchema = route.schema.response['200'] ||
362
+ route.schema.response['201'] ||
363
+ route.schema.response['2xx']
364
+ if (responseSchema) {
365
+ routeMeta.responseBody = jsonSchemaToTemplate(responseSchema)
366
+ }
367
+ } catch (e) {
368
+ // Ignore schema conversion errors
369
+ }
370
+ }
371
+
372
+ routes.push(routeMeta)
373
+
374
+ // Group into services
375
+ const serviceName = findServiceForRoute(routeMeta.path, options?.services)
376
+ if (!services[serviceName]) {
377
+ services[serviceName] = {
378
+ name: serviceName,
379
+ routes: [],
380
+ }
381
+ }
382
+ services[serviceName].routes.push(routeMeta)
383
+ }
384
+
385
+ vision.registerRoutes(routes)
386
+ vision.registerServices(Object.values(services))
387
+
388
+ console.info(`Vision: Discovered ${routes.length} routes across ${Object.keys(services).length} services`)
389
+ })
390
+ }
391
+
392
+ function jsonSchemaToTemplate(schema: any): RequestBodySchema {
393
+ if (!schema || typeof schema !== 'object') {
394
+ return {
395
+ template: '{}',
396
+ fields: [],
397
+ }
398
+ }
399
+
400
+ const lines: string[] = ['{']
401
+ const fields: SchemaField[] = []
402
+
403
+ if (schema.properties && typeof schema.properties === 'object') {
404
+ const props = Object.entries(schema.properties)
405
+ const required: string[] = Array.isArray(schema.required) ? schema.required : []
406
+
407
+ props.forEach(([key, prop]: [string, any], index) => {
408
+ const isRequired = required.includes(key)
409
+ const description = prop?.description || ''
410
+ const type = Array.isArray(prop?.type) ? prop.type[0] : prop?.type || 'any'
411
+
412
+ let value: string
413
+ switch (type) {
414
+ case 'string':
415
+ value = prop?.format === 'email' ? '"user@example.com"' : '"string"'
416
+ break
417
+ case 'number':
418
+ case 'integer':
419
+ value = '0'
420
+ break
421
+ case 'boolean':
422
+ value = 'false'
423
+ break
424
+ case 'array':
425
+ value = '[]'
426
+ break
427
+ case 'object':
428
+ value = '{}'
429
+ break
430
+ default:
431
+ value = 'null'
432
+ }
433
+
434
+ const comment = description
435
+ ? ` // ${description}${isRequired ? '' : ' (optional)'}`
436
+ : (isRequired ? '' : ' // optional')
437
+
438
+ const comma = index < props.length - 1 ? ',' : ''
439
+ lines.push(` "${key}": ${value}${comma}${comment}`)
440
+
441
+ fields.push({
442
+ name: key,
443
+ type,
444
+ description: description || undefined,
445
+ required: isRequired,
446
+ example: prop?.examples?.[0],
447
+ })
448
+ })
449
+ }
450
+
451
+ lines.push('}')
452
+ return {
453
+ template: lines.join('\n'),
454
+ fields,
455
+ }
456
+ }
457
+
458
+ function findServiceForRoute(path: string, customServices?: ServiceDefinition[]): string {
459
+ if (customServices) {
460
+ for (const service of customServices) {
461
+ for (const pattern of service.routes) {
462
+ if (matchPattern(path, pattern)) {
463
+ return service.name
464
+ }
465
+ }
466
+ }
467
+ }
468
+
469
+ const segments = path.split('/').filter(Boolean)
470
+ if (segments.length === 0) return 'Root'
471
+
472
+ // Find first non-param segment
473
+ const firstSegment = segments.find(s => !s.startsWith(':')) || segments[0]
474
+
475
+ // Skip param-only paths
476
+ if (firstSegment.startsWith(':')) return 'Root'
477
+
478
+ return firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1)
479
+ }
480
+
481
+ function matchPattern(path: string, pattern: string): boolean {
482
+ if (pattern.endsWith('/*')) {
483
+ const prefix = pattern.slice(0, -2)
484
+ return path === prefix || path.startsWith(prefix + '/')
485
+ }
486
+ return path === pattern
487
+ }
488
+
489
+ export { generateZodTemplate } from '@getvision/core'
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "extends": "../../packages/typescript-config/base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "lib": ["ES2022"],
7
+ "target": "ES2022",
8
+ "module": "ESNext",
9
+ "moduleResolution": "bundler"
10
+ },
11
+ "include": [
12
+ "src/**/*.ts",
13
+ "src/fastify.d.ts"
14
+ ],
15
+ "exclude": [
16
+ "node_modules",
17
+ "dist"
18
+ ]
19
+ }