@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/.turbo/turbo-build.log +1 -0
- package/README.md +298 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +382 -0
- package/package.json +47 -0
- package/src/fastify.d.ts +12 -0
- package/src/index.ts +489 -0
- package/tsconfig.json +19 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$ tsc
|
package/README.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# @getvision/adapter-fastify
|
|
2
|
+
|
|
3
|
+
Fastify adapter for Vision Dashboard.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @getvision/adapter-fastify fastify-type-provider-zod
|
|
9
|
+
# or
|
|
10
|
+
npm install @getvision/adapter-fastify fastify-type-provider-zod
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Note:** `fastify-type-provider-zod` is required for Zod schema validation.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import Fastify from 'fastify'
|
|
19
|
+
import { visionPlugin, enableAutoDiscovery } from '@getvision/adapter-fastify'
|
|
20
|
+
import { z } from 'zod'
|
|
21
|
+
import { serializerCompiler, validatorCompiler, type ZodTypeProvider } from 'fastify-type-provider-zod'
|
|
22
|
+
|
|
23
|
+
const app = Fastify()
|
|
24
|
+
|
|
25
|
+
// Add Zod validator and serializer
|
|
26
|
+
app.setValidatorCompiler(validatorCompiler)
|
|
27
|
+
app.setSerializerCompiler(serializerCompiler)
|
|
28
|
+
|
|
29
|
+
// Register Vision plugin (development only)
|
|
30
|
+
if (process.env.NODE_ENV === 'development') {
|
|
31
|
+
await app.register(visionPlugin, { port: 9500 })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Routes
|
|
35
|
+
app.get('/users', async (request, reply) => {
|
|
36
|
+
return { users: [] }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Zod validation with Fastify type provider
|
|
40
|
+
const CreateUserSchema = z.object({
|
|
41
|
+
name: z.string().min(1).describe('Full name'),
|
|
42
|
+
email: z.string().email().describe('Email'),
|
|
43
|
+
age: z.number().int().positive().optional().describe('Age (optional)'),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
app.withTypeProvider<ZodTypeProvider>().post('/users', {
|
|
47
|
+
schema: {
|
|
48
|
+
body: CreateUserSchema
|
|
49
|
+
}
|
|
50
|
+
}, async (request, reply) => {
|
|
51
|
+
return { id: 1, ...request.body }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Enable auto-discovery after routes
|
|
55
|
+
if (process.env.NODE_ENV === 'development') {
|
|
56
|
+
enableAutoDiscovery(app)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await app.listen({ port: 3000 })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Visit `http://localhost:9500` to see the dashboard! 🎉
|
|
63
|
+
|
|
64
|
+
## Features
|
|
65
|
+
|
|
66
|
+
### Automatic Request Tracing
|
|
67
|
+
Every request is automatically traced with:
|
|
68
|
+
- HTTP method, path, query params
|
|
69
|
+
- Request/response headers and body
|
|
70
|
+
- Status code and duration
|
|
71
|
+
- Root `http.request` span with child spans (DB, etc.)
|
|
72
|
+
|
|
73
|
+
### Custom Spans
|
|
74
|
+
Track operations within requests:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { useVisionSpan } from '@getvision/adapter-fastify'
|
|
78
|
+
|
|
79
|
+
app.get('/users', async (request, reply) => {
|
|
80
|
+
const withSpan = useVisionSpan()
|
|
81
|
+
|
|
82
|
+
const users = withSpan('db.select', {
|
|
83
|
+
'db.system': 'postgresql',
|
|
84
|
+
'db.table': 'users'
|
|
85
|
+
}, () => {
|
|
86
|
+
return db.select().from(users).all()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return { users }
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Auto-Discovery (Services Catalog)
|
|
94
|
+
Automatically discover all routes:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// Auto-group routes by first path segment (Users, Root, etc.)
|
|
98
|
+
enableAutoDiscovery(app)
|
|
99
|
+
|
|
100
|
+
// Or provide manual services grouping with glob-like patterns
|
|
101
|
+
enableAutoDiscovery(app, {
|
|
102
|
+
services: [
|
|
103
|
+
{ name: 'Users', description: 'User management', routes: ['/users/*'] },
|
|
104
|
+
{ name: 'Auth', routes: ['/auth/*'] }
|
|
105
|
+
]
|
|
106
|
+
})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## API
|
|
110
|
+
|
|
111
|
+
### `visionPlugin(options?)`
|
|
112
|
+
|
|
113
|
+
Fastify plugin for Vision.
|
|
114
|
+
|
|
115
|
+
**Options:**
|
|
116
|
+
```typescript
|
|
117
|
+
interface VisionFastifyOptions {
|
|
118
|
+
port?: number // Dashboard port (default: 9500)
|
|
119
|
+
enabled?: boolean // Enable Vision (default: true)
|
|
120
|
+
maxTraces?: number // Max traces to store (default: 1000)
|
|
121
|
+
maxLogs?: number // Max logs to store (default: 10000)
|
|
122
|
+
logging?: boolean // Console logging (default: true)
|
|
123
|
+
cors?: boolean // Auto CORS for dashboard (default: true)
|
|
124
|
+
service?: {
|
|
125
|
+
name?: string // Service name
|
|
126
|
+
version?: string // Service version
|
|
127
|
+
description?: string // Service description
|
|
128
|
+
integrations?: {
|
|
129
|
+
database?: string // Database connection
|
|
130
|
+
redis?: string // Redis connection
|
|
131
|
+
[key: string]: string | undefined
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### `enableAutoDiscovery(app, options?)`
|
|
138
|
+
|
|
139
|
+
Enable automatic route discovery for Fastify app.
|
|
140
|
+
|
|
141
|
+
**Note:** Call this after all routes are registered.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
type ServiceDefinition = {
|
|
145
|
+
name: string
|
|
146
|
+
description?: string
|
|
147
|
+
routes: string[] // e.g. ['/users/*']
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
enableAutoDiscovery(app, {
|
|
151
|
+
services: [
|
|
152
|
+
{ name: 'Users', routes: ['/users/*'] },
|
|
153
|
+
]
|
|
154
|
+
})
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### `useVisionSpan()`
|
|
158
|
+
|
|
159
|
+
Get span helper for current request. Child spans are automatically parented to the root `http.request` span for the current request.
|
|
160
|
+
|
|
161
|
+
**Returns:** `(name, attributes, fn) => result`
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
const withSpan = useVisionSpan()
|
|
165
|
+
|
|
166
|
+
const result = withSpan('operation.name', {
|
|
167
|
+
'attr.key': 'value'
|
|
168
|
+
}, () => {
|
|
169
|
+
// Your operation
|
|
170
|
+
return result
|
|
171
|
+
})
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### `getVisionContext()`
|
|
175
|
+
|
|
176
|
+
Get current Vision context.
|
|
177
|
+
|
|
178
|
+
**Returns:** `{ vision: VisionCore, traceId: string, rootSpanId: string }`
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
import { getVisionContext } from '@getvision/adapter-fastify'
|
|
182
|
+
|
|
183
|
+
app.get('/debug', async (request, reply) => {
|
|
184
|
+
const { vision, traceId } = getVisionContext()
|
|
185
|
+
return { traceId }
|
|
186
|
+
})
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### `getVisionInstance()`
|
|
190
|
+
|
|
191
|
+
Get the global Vision instance.
|
|
192
|
+
|
|
193
|
+
**Returns:** `VisionCore | null`
|
|
194
|
+
|
|
195
|
+
## Environment Variables
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
VISION_ENABLED=true # Enable/disable Vision
|
|
199
|
+
VISION_PORT=9500 # Dashboard port
|
|
200
|
+
NODE_ENV=development # Environment
|
|
201
|
+
|
|
202
|
+
# CORS notes
|
|
203
|
+
# Dashboard adds/needs headers: X-Vision-Trace-Id, X-Vision-Session
|
|
204
|
+
# The plugin auto-sets:
|
|
205
|
+
# Access-Control-Allow-Origin: *
|
|
206
|
+
# Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
|
|
207
|
+
# Access-Control-Allow-Headers: Content-Type, Authorization, X-Vision-Trace-Id, X-Vision-Session
|
|
208
|
+
# Access-Control-Expose-Headers: X-Vision-Trace-Id, X-Vision-Session
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Fastify-Specific Features
|
|
212
|
+
|
|
213
|
+
### Hooks Lifecycle
|
|
214
|
+
|
|
215
|
+
Vision uses Fastify hooks for tracing:
|
|
216
|
+
- `onRequest` - Create trace, start root span, add CORS headers
|
|
217
|
+
- `preHandler` - Run in AsyncLocalStorage context
|
|
218
|
+
- `onResponse` - Complete trace, end span, broadcast to dashboard
|
|
219
|
+
|
|
220
|
+
### Schema Support
|
|
221
|
+
|
|
222
|
+
Fastify's native schema support works with Zod:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
import { z } from 'zod'
|
|
226
|
+
|
|
227
|
+
const schema = z.object({
|
|
228
|
+
name: z.string(),
|
|
229
|
+
email: z.string().email()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
app.post('/users', {
|
|
233
|
+
schema: {
|
|
234
|
+
body: schema
|
|
235
|
+
}
|
|
236
|
+
}, async (request, reply) => {
|
|
237
|
+
// request.body is validated
|
|
238
|
+
return request.body
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Vision automatically extracts the schema and generates JSON templates for API Explorer.
|
|
243
|
+
|
|
244
|
+
## Integration with ORMs
|
|
245
|
+
|
|
246
|
+
### Prisma
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
app.get('/users', async (request, reply) => {
|
|
250
|
+
const withSpan = useVisionSpan()
|
|
251
|
+
|
|
252
|
+
const users = await withSpan('db.query', {
|
|
253
|
+
'db.system': 'postgresql',
|
|
254
|
+
'db.operation': 'findMany'
|
|
255
|
+
}, async () => {
|
|
256
|
+
return await prisma.user.findMany()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
return { users }
|
|
260
|
+
})
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Drizzle
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
app.get('/users', async (request, reply) => {
|
|
267
|
+
const withSpan = useVisionSpan()
|
|
268
|
+
|
|
269
|
+
const users = await withSpan('db.select', {
|
|
270
|
+
'db.system': 'postgresql',
|
|
271
|
+
'db.table': 'users'
|
|
272
|
+
}, async () => {
|
|
273
|
+
return await db.select().from(users)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
return { users }
|
|
277
|
+
})
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## TypeScript
|
|
281
|
+
|
|
282
|
+
Full TypeScript support included.
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
import type { VisionFastifyOptions } from '@getvision/adapter-fastify'
|
|
286
|
+
|
|
287
|
+
const options: VisionFastifyOptions = {
|
|
288
|
+
port: 9500,
|
|
289
|
+
service: {
|
|
290
|
+
name: 'my-api',
|
|
291
|
+
version: '1.0.0'
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## License
|
|
297
|
+
|
|
298
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import { VisionCore } from '@getvision/core';
|
|
3
|
+
import type { Trace, VisionFastifyOptions, ServiceDefinition } from '@getvision/core';
|
|
4
|
+
interface VisionContext {
|
|
5
|
+
vision: VisionCore;
|
|
6
|
+
trace: Trace;
|
|
7
|
+
traceId: string;
|
|
8
|
+
rootSpanId: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function getVisionContext(): VisionContext;
|
|
11
|
+
export declare function useVisionSpan(): <T>(name: string, attributes: Record<string, any> | undefined, fn: () => T) => T;
|
|
12
|
+
export declare function getVisionInstance(): VisionCore | null;
|
|
13
|
+
export declare const visionPlugin: FastifyPluginAsync<VisionFastifyOptions>;
|
|
14
|
+
export declare function enableAutoDiscovery(fastify: FastifyInstance, options?: {
|
|
15
|
+
services?: ServiceDefinition[];
|
|
16
|
+
}): void;
|
|
17
|
+
export { generateZodTemplate } from '@getvision/core';
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAElE,OAAO,EACL,UAAU,EAMX,MAAM,iBAAiB,CAAA;AACxB,OAAO,KAAK,EAIV,KAAK,EACL,oBAAoB,EACpB,iBAAiB,EAClB,MAAM,iBAAiB,CAAA;AAGxB,UAAU,aAAa;IACrB,MAAM,EAAE,UAAU,CAAA;IAClB,KAAK,EAAE,KAAK,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,gBAAgB,IAAI,aAAa,CAMhD;AAED,wBAAgB,aAAa,KAInB,CAAC,EACP,MAAM,MAAM,EACZ,YAAY,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,YAAK,EACpC,IAAI,MAAM,CAAC,KACV,CAAC,CAgCL;AAKD,wBAAgB,iBAAiB,IAAI,UAAU,GAAG,IAAI,CAErD;AA2OD,eAAO,MAAM,YAAY,0CAGvB,CAAA;AAEF,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,eAAe,EACxB,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,iBAAiB,EAAE,CAAA;CAAE,GAC3C,IAAI,CAgEN;AAmGD,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import fp from 'fastify-plugin';
|
|
2
|
+
import { VisionCore, autoDetectPackageInfo, autoDetectIntegrations, detectDrizzle, startDrizzleStudio, stopDrizzleStudio, } from '@getvision/core';
|
|
3
|
+
import { fastifyRequestContext, requestContext } from '@fastify/request-context';
|
|
4
|
+
export function getVisionContext() {
|
|
5
|
+
const ctx = requestContext.get('visionTrace');
|
|
6
|
+
if (!ctx) {
|
|
7
|
+
throw new Error('Vision context not available. Make sure visionPlugin is registered.');
|
|
8
|
+
}
|
|
9
|
+
return ctx;
|
|
10
|
+
}
|
|
11
|
+
export function useVisionSpan() {
|
|
12
|
+
const { vision, traceId, rootSpanId } = getVisionContext();
|
|
13
|
+
const tracer = vision.getTracer();
|
|
14
|
+
return (name, attributes = {}, fn) => {
|
|
15
|
+
const span = tracer.startSpan(name, traceId, rootSpanId);
|
|
16
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
17
|
+
tracer.setAttribute(span.id, key, value);
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const result = fn();
|
|
21
|
+
const completedSpan = tracer.endSpan(span.id);
|
|
22
|
+
if (completedSpan) {
|
|
23
|
+
vision.getTraceStore().addSpan(traceId, completedSpan);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
tracer.setAttribute(span.id, 'error', true);
|
|
29
|
+
tracer.setAttribute(span.id, 'error.message', error instanceof Error ? error.message : String(error));
|
|
30
|
+
const completedSpan = tracer.endSpan(span.id);
|
|
31
|
+
if (completedSpan) {
|
|
32
|
+
vision.getTraceStore().addSpan(traceId, completedSpan);
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
let visionInstance = null;
|
|
39
|
+
export function getVisionInstance() {
|
|
40
|
+
return visionInstance;
|
|
41
|
+
}
|
|
42
|
+
const visionPluginImpl = async (fastify, options) => {
|
|
43
|
+
const { port = 9500, enabled = true, maxTraces = 1000, maxLogs = 10000, logging = true, cors = true, } = options;
|
|
44
|
+
if (!enabled) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!visionInstance) {
|
|
48
|
+
visionInstance = new VisionCore({
|
|
49
|
+
port,
|
|
50
|
+
maxTraces,
|
|
51
|
+
maxLogs,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const vision = visionInstance;
|
|
55
|
+
await fastify.register(fastifyRequestContext, {
|
|
56
|
+
hook: 'onRequest',
|
|
57
|
+
});
|
|
58
|
+
fastify.addHook('onReady', async () => {
|
|
59
|
+
// Auto-detect service info
|
|
60
|
+
const pkgInfo = autoDetectPackageInfo();
|
|
61
|
+
const autoDetectedIntegrations = autoDetectIntegrations();
|
|
62
|
+
// Merge with user-provided config
|
|
63
|
+
const serviceName = options.service?.name || pkgInfo.name;
|
|
64
|
+
const serviceVersion = options.service?.version || pkgInfo.version;
|
|
65
|
+
const serviceDesc = options.service?.description;
|
|
66
|
+
const integrations = {
|
|
67
|
+
...autoDetectedIntegrations,
|
|
68
|
+
...options.service?.integrations,
|
|
69
|
+
};
|
|
70
|
+
// Filter out undefined values from integrations
|
|
71
|
+
const cleanIntegrations = {};
|
|
72
|
+
for (const [key, value] of Object.entries(integrations)) {
|
|
73
|
+
if (value !== undefined) {
|
|
74
|
+
cleanIntegrations[key] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Detect and optionally start Drizzle Studio
|
|
78
|
+
const drizzleInfo = detectDrizzle();
|
|
79
|
+
let drizzleStudioUrl;
|
|
80
|
+
if (drizzleInfo.detected) {
|
|
81
|
+
console.log(`🗄️ Drizzle detected (${drizzleInfo.configPath})`);
|
|
82
|
+
if (options.drizzle?.autoStart) {
|
|
83
|
+
const drizzlePort = options.drizzle.port || 4983;
|
|
84
|
+
const started = startDrizzleStudio(drizzlePort);
|
|
85
|
+
if (started) {
|
|
86
|
+
// Drizzle Studio uses local.drizzle.studio domain (with HTTPS)
|
|
87
|
+
drizzleStudioUrl = 'https://local.drizzle.studio';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
console.log('💡 Tip: Enable Drizzle Studio auto-start with drizzle: { autoStart: true }');
|
|
92
|
+
drizzleStudioUrl = 'https://local.drizzle.studio';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Set app status with service metadata
|
|
96
|
+
vision.setAppStatus({
|
|
97
|
+
name: serviceName,
|
|
98
|
+
version: serviceVersion,
|
|
99
|
+
description: serviceDesc,
|
|
100
|
+
running: true,
|
|
101
|
+
pid: process.pid,
|
|
102
|
+
metadata: {
|
|
103
|
+
framework: 'Fastify',
|
|
104
|
+
integrations: Object.keys(cleanIntegrations).length > 0 ? cleanIntegrations : undefined,
|
|
105
|
+
drizzle: drizzleInfo.detected
|
|
106
|
+
? {
|
|
107
|
+
detected: true,
|
|
108
|
+
configPath: drizzleInfo.configPath,
|
|
109
|
+
studioUrl: drizzleStudioUrl,
|
|
110
|
+
autoStarted: options.drizzle?.autoStart || false,
|
|
111
|
+
}
|
|
112
|
+
: undefined,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
// Cleanup on exit
|
|
116
|
+
process.on('SIGINT', () => {
|
|
117
|
+
stopDrizzleStudio();
|
|
118
|
+
process.exit(0);
|
|
119
|
+
});
|
|
120
|
+
process.on('SIGTERM', () => {
|
|
121
|
+
stopDrizzleStudio();
|
|
122
|
+
process.exit(0);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
const CAPTURE_KEY = Symbol.for('vision.fastify.routes');
|
|
126
|
+
const captured = (fastify[CAPTURE_KEY] = fastify[CAPTURE_KEY] || []);
|
|
127
|
+
fastify.addHook('onRoute', (routeOpts) => {
|
|
128
|
+
const methods = Array.isArray(routeOpts.method) ? routeOpts.method : [routeOpts.method];
|
|
129
|
+
for (const m of methods) {
|
|
130
|
+
const method = (m || '').toString().toUpperCase();
|
|
131
|
+
if (!method || method === 'HEAD' || method === 'OPTIONS')
|
|
132
|
+
continue;
|
|
133
|
+
captured.push({
|
|
134
|
+
method,
|
|
135
|
+
url: routeOpts.url,
|
|
136
|
+
schema: routeOpts.schema,
|
|
137
|
+
handlerName: routeOpts.handler?.name || 'anonymous',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
if (cors) {
|
|
142
|
+
fastify.options('/*', async (request, reply) => {
|
|
143
|
+
reply
|
|
144
|
+
.header('Access-Control-Allow-Origin', '*')
|
|
145
|
+
.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
|
|
146
|
+
.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Vision-Trace-Id, X-Vision-Session')
|
|
147
|
+
.header('Access-Control-Expose-Headers', 'X-Vision-Trace-Id, X-Vision-Session')
|
|
148
|
+
.code(204)
|
|
149
|
+
.send();
|
|
150
|
+
});
|
|
151
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
152
|
+
reply.header('Access-Control-Allow-Origin', '*');
|
|
153
|
+
reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
|
154
|
+
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Vision-Trace-Id, X-Vision-Session');
|
|
155
|
+
reply.header('Access-Control-Expose-Headers', 'X-Vision-Trace-Id, X-Vision-Session');
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
159
|
+
if (request.method === 'OPTIONS')
|
|
160
|
+
return;
|
|
161
|
+
const startTime = Date.now();
|
|
162
|
+
request.visionStartTime = startTime;
|
|
163
|
+
const trace = vision.createTrace(request.method, request.url);
|
|
164
|
+
reply.header('X-Vision-Trace-Id', trace.id);
|
|
165
|
+
const tracer = vision.getTracer();
|
|
166
|
+
const rootSpan = tracer.startSpan('http.request', trace.id);
|
|
167
|
+
request.requestContext.set('visionTrace', {
|
|
168
|
+
vision,
|
|
169
|
+
trace,
|
|
170
|
+
traceId: trace.id,
|
|
171
|
+
rootSpanId: rootSpan.id,
|
|
172
|
+
});
|
|
173
|
+
tracer.setAttribute(rootSpan.id, 'http.method', request.method);
|
|
174
|
+
tracer.setAttribute(rootSpan.id, 'http.path', request.url);
|
|
175
|
+
tracer.setAttribute(rootSpan.id, 'http.url', request.url);
|
|
176
|
+
if (request.query && Object.keys(request.query).length > 0) {
|
|
177
|
+
tracer.setAttribute(rootSpan.id, 'http.query', request.query);
|
|
178
|
+
}
|
|
179
|
+
const requestMeta = {
|
|
180
|
+
method: request.method,
|
|
181
|
+
url: request.url,
|
|
182
|
+
headers: request.headers,
|
|
183
|
+
query: Object.keys(request.query || {}).length ? request.query : undefined,
|
|
184
|
+
body: request.body,
|
|
185
|
+
};
|
|
186
|
+
tracer.setAttribute(rootSpan.id, 'http.request', requestMeta);
|
|
187
|
+
trace.metadata = { ...trace.metadata, request: requestMeta };
|
|
188
|
+
const sessionId = request.headers['x-vision-session'];
|
|
189
|
+
if (sessionId) {
|
|
190
|
+
tracer.setAttribute(rootSpan.id, 'session.id', sessionId);
|
|
191
|
+
trace.metadata = { ...trace.metadata, sessionId };
|
|
192
|
+
}
|
|
193
|
+
if (logging) {
|
|
194
|
+
const parts = [`method=${request.method}`, `path=${request.url}`];
|
|
195
|
+
if (sessionId)
|
|
196
|
+
parts.push(`sessionId=${sessionId}`);
|
|
197
|
+
parts.push(`traceId=${trace.id}`);
|
|
198
|
+
console.info(`INF starting request ${parts.join(' ')}`);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
fastify.addHook('onResponse', async (request, reply) => {
|
|
202
|
+
if (request.method === 'OPTIONS')
|
|
203
|
+
return;
|
|
204
|
+
const startTime = request.visionStartTime;
|
|
205
|
+
const context = request.requestContext.get('visionTrace');
|
|
206
|
+
if (!context || !startTime)
|
|
207
|
+
return;
|
|
208
|
+
const { vision, trace, traceId, rootSpanId } = context;
|
|
209
|
+
const tracer = vision.getTracer();
|
|
210
|
+
try {
|
|
211
|
+
const duration = Date.now() - startTime;
|
|
212
|
+
const rootSpan = tracer.getSpan(rootSpanId);
|
|
213
|
+
if (!rootSpan)
|
|
214
|
+
return;
|
|
215
|
+
tracer.setAttribute(rootSpan.id, 'http.status_code', reply.statusCode);
|
|
216
|
+
const responseMeta = {
|
|
217
|
+
status: reply.statusCode,
|
|
218
|
+
headers: reply.getHeaders(),
|
|
219
|
+
};
|
|
220
|
+
tracer.setAttribute(rootSpan.id, 'http.response', responseMeta);
|
|
221
|
+
trace.metadata = { ...trace.metadata, response: responseMeta };
|
|
222
|
+
const completedSpan = tracer.endSpan(rootSpan.id);
|
|
223
|
+
if (completedSpan) {
|
|
224
|
+
vision.getTraceStore().addSpan(traceId, completedSpan);
|
|
225
|
+
}
|
|
226
|
+
vision.completeTrace(traceId, reply.statusCode, duration);
|
|
227
|
+
if (logging) {
|
|
228
|
+
console.info(`INF request completed code=${reply.statusCode} duration=${duration}ms method=${request.method} path=${request.url} traceId=${traceId}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
console.error('Vision: Error completing trace:', error);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
export const visionPlugin = fp(visionPluginImpl, {
|
|
237
|
+
fastify: '5.x',
|
|
238
|
+
name: '@getvision/adapter-fastify'
|
|
239
|
+
});
|
|
240
|
+
export function enableAutoDiscovery(fastify, options) {
|
|
241
|
+
const vision = visionInstance;
|
|
242
|
+
if (!vision) {
|
|
243
|
+
console.warn('Vision not initialized. Call visionPlugin first.');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
fastify.addHook('onReady', async () => {
|
|
247
|
+
const routes = [];
|
|
248
|
+
const services = {};
|
|
249
|
+
// Use captured routes from onRoute hook
|
|
250
|
+
const CAPTURE_KEY = Symbol.for('vision.fastify.routes');
|
|
251
|
+
const capturedRoutes = fastify[CAPTURE_KEY] || [];
|
|
252
|
+
for (const route of capturedRoutes) {
|
|
253
|
+
const routeMeta = {
|
|
254
|
+
method: route.method,
|
|
255
|
+
path: route.url,
|
|
256
|
+
handler: route.handlerName || 'anonymous',
|
|
257
|
+
};
|
|
258
|
+
// Try to get schema from route
|
|
259
|
+
if (route.schema?.body) {
|
|
260
|
+
try {
|
|
261
|
+
routeMeta.requestBody = jsonSchemaToTemplate(route.schema.body);
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
// Ignore schema conversion errors
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Try to get response schema (Fastify supports response: { 200: { ... } })
|
|
268
|
+
if (route.schema?.response) {
|
|
269
|
+
try {
|
|
270
|
+
// Get the success response schema (200, 201, etc.)
|
|
271
|
+
const responseSchema = route.schema.response['200'] ||
|
|
272
|
+
route.schema.response['201'] ||
|
|
273
|
+
route.schema.response['2xx'];
|
|
274
|
+
if (responseSchema) {
|
|
275
|
+
routeMeta.responseBody = jsonSchemaToTemplate(responseSchema);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
// Ignore schema conversion errors
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
routes.push(routeMeta);
|
|
283
|
+
// Group into services
|
|
284
|
+
const serviceName = findServiceForRoute(routeMeta.path, options?.services);
|
|
285
|
+
if (!services[serviceName]) {
|
|
286
|
+
services[serviceName] = {
|
|
287
|
+
name: serviceName,
|
|
288
|
+
routes: [],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
services[serviceName].routes.push(routeMeta);
|
|
292
|
+
}
|
|
293
|
+
vision.registerRoutes(routes);
|
|
294
|
+
vision.registerServices(Object.values(services));
|
|
295
|
+
console.info(`Vision: Discovered ${routes.length} routes across ${Object.keys(services).length} services`);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
function jsonSchemaToTemplate(schema) {
|
|
299
|
+
if (!schema || typeof schema !== 'object') {
|
|
300
|
+
return {
|
|
301
|
+
template: '{}',
|
|
302
|
+
fields: [],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const lines = ['{'];
|
|
306
|
+
const fields = [];
|
|
307
|
+
if (schema.properties && typeof schema.properties === 'object') {
|
|
308
|
+
const props = Object.entries(schema.properties);
|
|
309
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
310
|
+
props.forEach(([key, prop], index) => {
|
|
311
|
+
const isRequired = required.includes(key);
|
|
312
|
+
const description = prop?.description || '';
|
|
313
|
+
const type = Array.isArray(prop?.type) ? prop.type[0] : prop?.type || 'any';
|
|
314
|
+
let value;
|
|
315
|
+
switch (type) {
|
|
316
|
+
case 'string':
|
|
317
|
+
value = prop?.format === 'email' ? '"user@example.com"' : '"string"';
|
|
318
|
+
break;
|
|
319
|
+
case 'number':
|
|
320
|
+
case 'integer':
|
|
321
|
+
value = '0';
|
|
322
|
+
break;
|
|
323
|
+
case 'boolean':
|
|
324
|
+
value = 'false';
|
|
325
|
+
break;
|
|
326
|
+
case 'array':
|
|
327
|
+
value = '[]';
|
|
328
|
+
break;
|
|
329
|
+
case 'object':
|
|
330
|
+
value = '{}';
|
|
331
|
+
break;
|
|
332
|
+
default:
|
|
333
|
+
value = 'null';
|
|
334
|
+
}
|
|
335
|
+
const comment = description
|
|
336
|
+
? ` // ${description}${isRequired ? '' : ' (optional)'}`
|
|
337
|
+
: (isRequired ? '' : ' // optional');
|
|
338
|
+
const comma = index < props.length - 1 ? ',' : '';
|
|
339
|
+
lines.push(` "${key}": ${value}${comma}${comment}`);
|
|
340
|
+
fields.push({
|
|
341
|
+
name: key,
|
|
342
|
+
type,
|
|
343
|
+
description: description || undefined,
|
|
344
|
+
required: isRequired,
|
|
345
|
+
example: prop?.examples?.[0],
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
lines.push('}');
|
|
350
|
+
return {
|
|
351
|
+
template: lines.join('\n'),
|
|
352
|
+
fields,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function findServiceForRoute(path, customServices) {
|
|
356
|
+
if (customServices) {
|
|
357
|
+
for (const service of customServices) {
|
|
358
|
+
for (const pattern of service.routes) {
|
|
359
|
+
if (matchPattern(path, pattern)) {
|
|
360
|
+
return service.name;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const segments = path.split('/').filter(Boolean);
|
|
366
|
+
if (segments.length === 0)
|
|
367
|
+
return 'Root';
|
|
368
|
+
// Find first non-param segment
|
|
369
|
+
const firstSegment = segments.find(s => !s.startsWith(':')) || segments[0];
|
|
370
|
+
// Skip param-only paths
|
|
371
|
+
if (firstSegment.startsWith(':'))
|
|
372
|
+
return 'Root';
|
|
373
|
+
return firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1);
|
|
374
|
+
}
|
|
375
|
+
function matchPattern(path, pattern) {
|
|
376
|
+
if (pattern.endsWith('/*')) {
|
|
377
|
+
const prefix = pattern.slice(0, -2);
|
|
378
|
+
return path === prefix || path.startsWith(prefix + '/');
|
|
379
|
+
}
|
|
380
|
+
return path === pattern;
|
|
381
|
+
}
|
|
382
|
+
export { generateZodTemplate } from '@getvision/core';
|