graphql-watchdog 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Stuart
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,521 @@
1
+ # graphql-watchdog
2
+
3
+ [![CI](https://github.com/mstuart/graphql-watchdog/actions/workflows/ci.yml/badge.svg)](https://github.com/mstuart/graphql-watchdog/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/graphql-watchdog)](https://www.npmjs.com/package/graphql-watchdog)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ GraphQL performance toolkit -- N+1 detection, normalized caching, cost analysis, and CI regression testing.
8
+
9
+ ## Features
10
+
11
+ - **N+1 Query Detection** -- Automatically detects N+1 patterns in resolver execution and suggests DataLoader fixes
12
+ - **Query Cost Analysis** -- AST-based cost calculation with configurable field costs, list multipliers, and hard limits
13
+ - **Query Optimization Suggestions** -- Analyzes queries and suggests pagination, fragments, DataLoader usage, and more
14
+ - **Dynamic Cost Tracking** -- Automatically derives cost weights from actual resolver performance data
15
+ - **Normalized Response Cache** -- Entity-level caching with LRU eviction, TTL expiration, and type/entity-based invalidation
16
+ - **Pluggable Cache Backends** -- In-memory, Redis, and Cloudflare KV backends via the CacheBackend interface
17
+ - **Performance Dashboard** -- Self-contained HTML dashboard with score gauges, charts, and trend tracking
18
+ - **Server Plugins** -- Drop-in plugins for GraphQL Yoga and Apollo Server
19
+ - **CLI Tooling** -- Static analysis and benchmarking commands for CI/CD pipelines
20
+ - **CI Regression Testing** -- Benchmark operations against endpoints with p50/p95/p99 tracking and regression detection
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ npm install graphql-watchdog graphql
26
+ ```
27
+
28
+ ### With GraphQL Yoga
29
+
30
+ ```typescript
31
+ import { createYoga, createSchema } from 'graphql-yoga';
32
+ import { useWatchdog } from 'graphql-watchdog';
33
+
34
+ const yoga = createYoga({
35
+ schema: createSchema({ /* your schema */ }),
36
+ plugins: [
37
+ useWatchdog({
38
+ enableDetector: true,
39
+ enableCost: true,
40
+ cost: {
41
+ maxCost: 1000,
42
+ defaultListMultiplier: 10,
43
+ },
44
+ enableCache: true,
45
+ cache: {
46
+ maxSize: 500,
47
+ ttl: 60000,
48
+ },
49
+ }),
50
+ ],
51
+ });
52
+ ```
53
+
54
+ ### With Apollo Server
55
+
56
+ ```typescript
57
+ import { ApolloServer } from '@apollo/server';
58
+ import { watchdogApolloPlugin } from 'graphql-watchdog';
59
+
60
+ const server = new ApolloServer({
61
+ typeDefs,
62
+ resolvers,
63
+ plugins: [
64
+ watchdogApolloPlugin({
65
+ enableDetector: true,
66
+ onDetection: (detections) => {
67
+ detections.forEach((d) => {
68
+ console.warn(`N+1 detected: ${d.field} (${d.callCount} calls)`);
69
+ });
70
+ },
71
+ }),
72
+ ],
73
+ });
74
+ ```
75
+
76
+ ## Requirements
77
+
78
+ - Node.js >= 18.0.0
79
+ - `graphql` >= 16.0.0 (peer dependency)
80
+ - `graphql-yoga` >= 5.0.0 (optional, for Yoga plugin)
81
+ - `@apollo/server` >= 4.0.0 (optional, for Apollo plugin)
82
+ - `ioredis` >= 5.0.0 (optional, for Redis cache backend)
83
+ - TypeScript >= 5.0 (optional, for type definitions)
84
+
85
+ Fully written in TypeScript with complete type exports for all public APIs.
86
+
87
+ ## Usage
88
+
89
+ ### N+1 Detection
90
+
91
+ The detector instruments resolver functions to track execution patterns and identify N+1 queries:
92
+
93
+ ```typescript
94
+ import { ResolverInstrumenter, analyzeForN1 } from 'graphql-watchdog';
95
+
96
+ const instrumenter = new ResolverInstrumenter();
97
+ const instrumented = instrumenter.instrumentResolvers(resolvers);
98
+
99
+ // ... execute GraphQL operations using instrumented resolvers ...
100
+
101
+ const detections = analyzeForN1(instrumenter.getCalls());
102
+ // [{ field: 'Post.author', callCount: 10, severity: 'critical', suggestion: '...' }]
103
+ ```
104
+
105
+ ### Cost Analysis
106
+
107
+ Analyze query cost statically from the AST:
108
+
109
+ ```typescript
110
+ import { analyzeCost, costLimitRule } from 'graphql-watchdog';
111
+ import { parse, validate } from 'graphql';
112
+
113
+ const query = parse(`
114
+ query {
115
+ posts(first: 20) {
116
+ title
117
+ author { name }
118
+ comments(first: 10) { text }
119
+ }
120
+ }
121
+ `);
122
+
123
+ const breakdown = analyzeCost(query, schema, {
124
+ maxCost: 500,
125
+ defaultListMultiplier: 10,
126
+ costMap: {
127
+ 'Query.posts': 2,
128
+ 'Post.comments': 5,
129
+ },
130
+ });
131
+
132
+ console.log(breakdown.totalCost); // calculated cost
133
+ console.log(breakdown.exceeds); // true if over maxCost
134
+
135
+ // Or use as a validation rule
136
+ const errors = validate(schema, query, [costLimitRule(schema, { maxCost: 500 })]);
137
+ ```
138
+
139
+ ### Query Optimization Suggestions
140
+
141
+ Analyze queries and get actionable optimization suggestions:
142
+
143
+ ```typescript
144
+ import { analyzeCost, suggestOptimizations } from 'graphql-watchdog';
145
+ import { parse } from 'graphql';
146
+
147
+ const query = parse(`
148
+ query {
149
+ allUsers {
150
+ name
151
+ posts {
152
+ title
153
+ author { name }
154
+ comments {
155
+ text
156
+ author { name }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ `);
162
+
163
+ const breakdown = analyzeCost(query, schema);
164
+ const suggestions = suggestOptimizations(breakdown, query, schema);
165
+
166
+ for (const suggestion of suggestions) {
167
+ console.log(`[${suggestion.severity}] ${suggestion.type}: ${suggestion.message}`);
168
+ console.log(` Estimated saving: ${suggestion.estimatedSaving}`);
169
+ }
170
+ ```
171
+
172
+ Suggestion types:
173
+ - **pagination** -- Unbounded list fields missing `first`/`limit` arguments
174
+ - **field-pruning** -- Deeply nested fields contributing disproportionate cost
175
+ - **depth-reduction** -- Queries exceeding 5 levels of nesting
176
+ - **fragment** -- Repeated selection sets that could use fragments
177
+ - **dataloader** -- Object fields under list parents likely causing N+1 queries
178
+
179
+ ### Dynamic Cost Tracking
180
+
181
+ Derive cost weights automatically from actual resolver performance:
182
+
183
+ ```typescript
184
+ import { DynamicCostTracker, ResolverInstrumenter, analyzeCost } from 'graphql-watchdog';
185
+
186
+ // Create tracker and wire it to the instrumenter
187
+ const tracker = new DynamicCostTracker();
188
+ const instrumenter = new ResolverInstrumenter({ costTracker: tracker });
189
+ const instrumented = instrumenter.instrumentResolvers(resolvers);
190
+
191
+ // ... execute queries -- timing data is recorded automatically ...
192
+
193
+ // Generate cost config from observed performance
194
+ const costConfig = tracker.toCostConfig({
195
+ baselineDuration: 10, // 10ms = cost 1
196
+ });
197
+
198
+ // Use dynamic costs for analysis
199
+ const breakdown = analyzeCost(query, schema, costConfig);
200
+
201
+ // Export timing data for persistence
202
+ const timingData = tracker.export();
203
+ saveToFile(timingData);
204
+
205
+ // Import on restart
206
+ const saved = loadFromFile();
207
+ tracker.import(saved);
208
+
209
+ // Get stats
210
+ const stats = tracker.getStats();
211
+ console.log(`Tracking ${stats.trackedFields} fields, ${stats.totalCalls} total calls`);
212
+ console.log('Slowest fields:', stats.slowestFields);
213
+ ```
214
+
215
+ ### Response Cache
216
+
217
+ Normalized caching with automatic invalidation:
218
+
219
+ ```typescript
220
+ import { ResponseCache, normalizeResponse, getMutationTypes } from 'graphql-watchdog';
221
+
222
+ const cache = new ResponseCache({
223
+ maxSize: 1000,
224
+ ttl: 60000, // 1 minute
225
+ });
226
+
227
+ // Cache a response
228
+ const { entities, cacheKey } = normalizeResponse(data, 'GetPosts', variables);
229
+ cache.set(cacheKey, data, entities);
230
+
231
+ // Retrieve from cache
232
+ const cached = cache.get(cacheKey);
233
+
234
+ // Invalidate after mutations
235
+ const affectedTypes = getMutationTypes(mutationDocument, schema);
236
+ affectedTypes.forEach((type) => cache.invalidateByType(type));
237
+
238
+ // Check stats
239
+ const stats = cache.getStats();
240
+ // { hits: 50, misses: 10, hitRate: 0.833, entries: 25 }
241
+ ```
242
+
243
+ ### Cache Backends
244
+
245
+ The cache supports pluggable backends via the `CacheBackend` interface. The default is in-memory, but you can use Redis or Cloudflare KV.
246
+
247
+ #### Redis Backend
248
+
249
+ ```bash
250
+ npm install ioredis # optional peer dependency
251
+ ```
252
+
253
+ ```typescript
254
+ import { ResponseCache, RedisCacheBackend } from 'graphql-watchdog';
255
+
256
+ const redisBackend = new RedisCacheBackend({
257
+ url: 'redis://localhost:6379',
258
+ keyPrefix: 'gql-watchdog:',
259
+ });
260
+
261
+ await redisBackend.connect();
262
+
263
+ const cache = new ResponseCache({
264
+ maxSize: 10000,
265
+ ttl: 300000,
266
+ backend: redisBackend,
267
+ });
268
+
269
+ // Use cache as normal -- data persists in Redis
270
+ cache.set(cacheKey, data, entities);
271
+
272
+ // For backend-backed caches, use getAsync:
273
+ const cached = await cache.getAsync(cacheKey);
274
+
275
+ // Disconnect when done
276
+ await redisBackend.disconnect();
277
+ ```
278
+
279
+ #### Cloudflare KV Backend
280
+
281
+ No additional dependencies -- uses the Workers KV API available at runtime:
282
+
283
+ ```typescript
284
+ import { ResponseCache, CloudflareKVBackend } from 'graphql-watchdog';
285
+
286
+ // In a Cloudflare Worker:
287
+ export default {
288
+ async fetch(request, env) {
289
+ const kvBackend = new CloudflareKVBackend({
290
+ namespace: env.GQL_CACHE, // KV namespace binding
291
+ keyPrefix: 'cache:',
292
+ });
293
+
294
+ const cache = new ResponseCache({
295
+ ttl: 300000,
296
+ backend: kvBackend,
297
+ });
298
+
299
+ // Use normally
300
+ },
301
+ };
302
+ ```
303
+
304
+ #### Custom Backend
305
+
306
+ Implement the `CacheBackend` interface to create your own:
307
+
308
+ ```typescript
309
+ import type { CacheBackend } from 'graphql-watchdog';
310
+
311
+ class MyCustomBackend implements CacheBackend {
312
+ async get(key: string): Promise<string | null> { /* ... */ }
313
+ async set(key: string, value: string, ttlMs?: number): Promise<void> { /* ... */ }
314
+ async del(key: string): Promise<void> { /* ... */ }
315
+ async keys(pattern: string): Promise<string[]> { /* ... */ }
316
+ async delMany(keys: string[]): Promise<number> { /* ... */ }
317
+ async clear(): Promise<void> { /* ... */ }
318
+ }
319
+ ```
320
+
321
+ ### Performance Dashboard
322
+
323
+ Generate a self-contained HTML performance dashboard:
324
+
325
+ ```typescript
326
+ import { generateReport, generateDashboard } from 'graphql-watchdog';
327
+
328
+ // Via generateReport
329
+ const html = generateReport(performanceReport, 'dashboard');
330
+
331
+ // Or directly
332
+ const html = generateDashboard(performanceReport);
333
+
334
+ // Write to file
335
+ import { writeFileSync } from 'fs';
336
+ writeFileSync('dashboard.html', html);
337
+ ```
338
+
339
+ The dashboard includes:
340
+ - **Performance score** (0-100) based on N+1 count, cost, and cache hit rate
341
+ - **N+1 hotspots table** with field, call count, severity, and DataLoader suggestions
342
+ - **Cost breakdown chart** (inline SVG bar chart)
343
+ - **Cache stats gauge** with hit rate, entries, and miss count
344
+ - **Operations table** sorted by duration
345
+ - **localStorage integration** for tracking trends across runs
346
+ - Dark theme, fully self-contained (no external dependencies)
347
+
348
+ ### Reporting
349
+
350
+ Generate performance reports in terminal, JSON, or dashboard format:
351
+
352
+ ```typescript
353
+ import { generateReport } from 'graphql-watchdog';
354
+
355
+ const report = generateReport(performanceReport, 'terminal'); // colored terminal output
356
+ const json = generateReport(performanceReport, 'json'); // machine-readable JSON
357
+ const html = generateReport(performanceReport, 'dashboard'); // self-contained HTML dashboard
358
+ ```
359
+
360
+ ## CLI
361
+
362
+ ### Analyze
363
+
364
+ Run static cost analysis on GraphQL operations:
365
+
366
+ ```bash
367
+ graphql-watchdog analyze --schema schema.graphql --operations "queries/**/*.graphql" --max-cost 500
368
+ ```
369
+
370
+ Options:
371
+ - `--schema <path>` -- Path to GraphQL schema SDL file (required)
372
+ - `--operations <glob>` -- Glob pattern for .graphql operation files (required)
373
+ - `--max-cost <number>` -- Maximum allowed query cost
374
+ - `--default-list-multiplier <number>` -- Default multiplier for list fields
375
+ - `--format <terminal|json>` -- Output format (default: terminal)
376
+
377
+ ### Benchmark
378
+
379
+ Benchmark GraphQL operations with regression detection:
380
+
381
+ ```bash
382
+ # Run benchmarks
383
+ graphql-watchdog benchmark \
384
+ --endpoint http://localhost:4000/graphql \
385
+ --operations "queries/**/*.graphql" \
386
+ --iterations 50 \
387
+ --output baseline.json
388
+
389
+ # Compare against baseline (exits 1 on regression)
390
+ graphql-watchdog benchmark \
391
+ --endpoint http://localhost:4000/graphql \
392
+ --operations "queries/**/*.graphql" \
393
+ --baseline baseline.json \
394
+ --threshold 20
395
+ ```
396
+
397
+ Options:
398
+ - `--endpoint <url>` -- GraphQL endpoint URL (required)
399
+ - `--operations <glob>` -- Glob pattern for .graphql files (required)
400
+ - `--baseline <file>` -- Baseline JSON for regression comparison
401
+ - `--iterations <n>` -- Iterations per operation (default: 10)
402
+ - `--output <file>` -- Save results to JSON file
403
+ - `--threshold <percent>` -- Regression threshold % (default: 20)
404
+
405
+ ## Comparison with Alternatives
406
+
407
+ graphql-watchdog combines several capabilities that would otherwise require multiple packages:
408
+
409
+ | Feature | graphql-watchdog | graphql-query-complexity | graphql-depth-limit | apollo-server-plugin-response-cache |
410
+ |---------|-----------------|------------------------|--------------------|-------------------------------------|
411
+ | Cost analysis | Yes | Yes | No | No |
412
+ | N+1 detection | Yes | No | No | No |
413
+ | Normalized response cache | Yes | No | No | Yes |
414
+ | Dynamic cost tracking | Yes | No | No | No |
415
+ | Optimization suggestions | Yes | No | No | No |
416
+ | Pluggable cache backends (Redis, CF KV) | Yes | No | No | No |
417
+ | CI benchmark regression testing | Yes | No | No | No |
418
+ | Performance dashboard | Yes | No | No | No |
419
+ | Yoga + Apollo plugins | Yes | Partial | Partial | Apollo only |
420
+
421
+ Choose graphql-watchdog if you want a unified performance toolkit. Choose individual packages if you only need one specific capability.
422
+
423
+ ## Configuration Reference
424
+
425
+ ### WatchdogConfig
426
+
427
+ | Option | Type | Default | Description |
428
+ |--------|------|---------|-------------|
429
+ | `enableDetector` | `boolean` | `true` | Enable N+1 detection |
430
+ | `enableCost` | `boolean` | `true` | Enable cost analysis |
431
+ | `enableCache` | `boolean` | `false` | Enable response caching |
432
+ | `cost` | `CostConfig` | `{}` | Cost analysis configuration |
433
+ | `cache` | `CacheConfig` | `{}` | Cache configuration |
434
+ | `dynamicCost` | `boolean` | `false` | Enable dynamic cost tracking |
435
+ | `dynamicCostBaseline` | `number` | `10` | Milliseconds per cost unit for dynamic tracking |
436
+
437
+ ### CostConfig
438
+
439
+ | Option | Type | Default | Description |
440
+ |--------|------|---------|-------------|
441
+ | `defaultFieldCost` | `number` | `1` | Default cost per field |
442
+ | `defaultListMultiplier` | `number` | `10` | Default multiplier for list fields |
443
+ | `costMap` | `Record<string, number>` | `{}` | Custom costs by `TypeName.fieldName` |
444
+ | `maxCost` | `number` | `Infinity` | Maximum allowed query cost |
445
+
446
+ ### CacheConfig
447
+
448
+ | Option | Type | Default | Description |
449
+ |--------|------|---------|-------------|
450
+ | `maxSize` | `number` | `1000` | Maximum cache entries |
451
+ | `ttl` | `number` | `60000` | Time-to-live in milliseconds |
452
+ | `invalidateOnMutation` | `boolean` | `true` | Auto-invalidate on mutations |
453
+ | `backend` | `CacheBackend` | `undefined` | External cache backend (Redis, Cloudflare KV, custom) |
454
+
455
+ ## API Reference
456
+
457
+ ### Detection
458
+
459
+ - `ResolverInstrumenter` -- Wraps resolvers to track execution
460
+ - `constructor(options?)` -- Optional `{ costTracker: DynamicCostTracker }` for automatic timing
461
+ - `.instrumentResolvers(resolvers)` -- Returns instrumented resolver map
462
+ - `.getCalls()` -- Returns recorded resolver calls
463
+ - `.reset()` -- Clears recorded calls
464
+ - `analyzeForN1(calls, threshold?)` -- Analyzes calls for N+1 patterns
465
+
466
+ ### Cost
467
+
468
+ - `analyzeCost(document, schema, config?, variables?)` -- Returns cost breakdown
469
+ - `costLimitRule(schema, config)` -- GraphQL validation rule for cost limits
470
+ - `suggestOptimizations(breakdown, document, schema, config?)` -- Returns optimization suggestions
471
+ - `DynamicCostTracker` -- Tracks resolver performance and generates cost configs
472
+ - `.recordTiming(typeName, fieldName, durationMs)` -- Record a resolver timing
473
+ - `.toCostConfig(options?)` -- Generate CostConfig from observed data
474
+ - `.export()` -- Export timing data for persistence
475
+ - `.import(data)` -- Import previously saved timing data
476
+ - `.getStats()` -- Get summary statistics
477
+
478
+ ### Cache
479
+
480
+ - `ResponseCache` -- LRU cache with TTL and entity normalization
481
+ - `.set(key, data, entities)` -- Store response
482
+ - `.get(key)` -- Retrieve response (null if expired/missing)
483
+ - `.getAsync(key)` -- Async retrieve (required for backend-backed caches)
484
+ - `.invalidateByType(typename)` -- Invalidate by type name
485
+ - `.invalidateByEntity(typename, id)` -- Invalidate by specific entity
486
+ - `.getStats()` -- Get hit/miss statistics
487
+ - `.clear()` -- Clear all entries
488
+ - `normalizeResponse(data, operationName, variables?)` -- Normalize response data
489
+ - `getMutationTypes(document, schema)` -- Extract mutation return types
490
+
491
+ ### Cache Backends
492
+
493
+ - `CacheBackend` -- Interface for pluggable cache storage
494
+ - `MemoryCacheBackend` -- In-memory implementation with TTL support
495
+ - `RedisCacheBackend` -- Redis backend (requires `ioredis` peer dependency)
496
+ - `.connect()` / `.disconnect()` -- Manage connection lifecycle
497
+ - `CloudflareKVBackend` -- Cloudflare Workers KV backend (no deps needed)
498
+
499
+ ### Plugins
500
+
501
+ - `useWatchdog(config?)` -- GraphQL Yoga plugin
502
+ - `watchdogApolloPlugin(config?)` -- Apollo Server plugin
503
+
504
+ ### Reporting
505
+
506
+ - `generateReport(report, format?)` -- Generate formatted report (`'terminal'`, `'json'`, or `'dashboard'`)
507
+ - `generateDashboard(report)` -- Generate self-contained HTML dashboard
508
+ - `calculatePerformanceScore(report)` -- Calculate 0-100 performance score
509
+
510
+ ## Contributing
511
+
512
+ 1. Fork the repository
513
+ 2. Create your feature branch (`git checkout -b feature/my-feature`)
514
+ 3. Run tests (`npm test`)
515
+ 4. Commit your changes (`git commit -am 'Add my feature'`)
516
+ 5. Push to the branch (`git push origin feature/my-feature`)
517
+ 6. Open a Pull Request
518
+
519
+ ## License
520
+
521
+ MIT