Skip to content

Commit 6ef198a

Browse files
committed
Add server-side diagnostic warnings for missing probe data (#25)
* Fix npm badge link to point to package page instead of search * Add server-side diagnostic warnings for missing probe data Surface misconfigurations server-side since the probe client has no room for error reporting within its 1 KB budget. Three strategies, all gated behind NODE_ENV !== 'production': - Startup: log the expected probe endpoint path via console.info - Runtime: one-time console.warn after 50 middleware hits with zero probe submissions - onEvent: emit structured diagnostic:no-probe-data event through the existing onEvent callback
1 parent b713bdc commit 6ef198a

14 files changed

Lines changed: 1120 additions & 67 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# DeviceRouter
22

3-
[![npm](https://img.shields.io/npm/v/@device-router/types?label=npm&color=cb3837)](https://www.npmjs.com/search?q=%40device-router)
3+
[![npm](https://img.shields.io/npm/v/@device-router/types?label=npm&color=cb3837)](https://www.npmjs.com/package/@device-router/types)
44
[![CI](https://img.shields.io/github/actions/workflow/status/SimplyLiz/DeviceRouter/ci.yml?branch=main&label=CI)](https://github.com/SimplyLiz/DeviceRouter/actions/workflows/ci.yml)
55
[![bundle size](https://img.shields.io/badge/probe-~1%20KB%20gzipped-blue)](https://github.com/SimplyLiz/DeviceRouter/tree/main/packages/probe)
66
[![license](https://img.shields.io/github/license/SimplyLiz/DeviceRouter)](https://github.com/SimplyLiz/DeviceRouter/blob/main/LICENSE)
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import express from 'express';
3+
import cookieParser from 'cookie-parser';
4+
import { createDeviceRouter } from '../index.js';
5+
import { MemoryStorageAdapter } from '@device-router/storage';
6+
import { NO_PROBE_DATA_THRESHOLD } from '@device-router/types';
7+
import type { Server } from 'node:http';
8+
9+
describe('diagnostics (express)', () => {
10+
const originalEnv = process.env.NODE_ENV;
11+
12+
afterEach(() => {
13+
process.env.NODE_ENV = originalEnv;
14+
vi.restoreAllMocks();
15+
});
16+
17+
describe('Strategy A: startup log', () => {
18+
it('logs probe path at startup in non-production', () => {
19+
process.env.NODE_ENV = 'development';
20+
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
21+
const storage = new MemoryStorageAdapter();
22+
23+
createDeviceRouter({ storage });
24+
25+
expect(infoSpy).toHaveBeenCalledWith(
26+
'[DeviceRouter] Probe endpoint expected at POST /device-router/probe',
27+
);
28+
});
29+
30+
it('logs custom probe path', () => {
31+
process.env.NODE_ENV = 'development';
32+
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
33+
const storage = new MemoryStorageAdapter();
34+
35+
createDeviceRouter({ storage, probePath: '/custom/probe' });
36+
37+
expect(infoSpy).toHaveBeenCalledWith(
38+
'[DeviceRouter] Probe endpoint expected at POST /custom/probe',
39+
);
40+
});
41+
42+
it('does not log in production', () => {
43+
process.env.NODE_ENV = 'production';
44+
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
45+
const storage = new MemoryStorageAdapter();
46+
47+
createDeviceRouter({ storage });
48+
49+
expect(infoSpy).not.toHaveBeenCalled();
50+
});
51+
});
52+
53+
describe('Strategy B: runtime warning', () => {
54+
let app: ReturnType<typeof express>;
55+
let server: Server;
56+
let baseUrl: string;
57+
58+
beforeEach(async () => {
59+
process.env.NODE_ENV = 'development';
60+
vi.spyOn(console, 'info').mockImplementation(() => {});
61+
});
62+
63+
afterEach(async () => {
64+
if (server) {
65+
await new Promise<void>((resolve) => server.close(() => resolve()));
66+
}
67+
});
68+
69+
async function setup(opts: { onEvent?: (e: unknown) => void } = {}) {
70+
const storage = new MemoryStorageAdapter();
71+
vi.spyOn(console, 'warn').mockImplementation(() => {});
72+
app = express();
73+
app.use(cookieParser());
74+
app.use(express.json());
75+
76+
const { middleware, probeEndpoint } = createDeviceRouter({
77+
storage,
78+
onEvent: opts.onEvent,
79+
});
80+
81+
app.post('/device-router/probe', probeEndpoint);
82+
app.use(middleware);
83+
app.get('/test', (_req, res) => res.json({ ok: true }));
84+
85+
await new Promise<void>((resolve) => {
86+
server = app.listen(0, () => resolve());
87+
});
88+
const addr = server.address();
89+
if (typeof addr === 'object' && addr) {
90+
baseUrl = `http://127.0.0.1:${addr.port}`;
91+
}
92+
}
93+
94+
it('warns after threshold middleware hits with no probe', async () => {
95+
await setup();
96+
const warnSpy = vi.mocked(console.warn);
97+
98+
for (let i = 0; i < NO_PROBE_DATA_THRESHOLD; i++) {
99+
await fetch(`${baseUrl}/test`);
100+
}
101+
102+
expect(warnSpy).toHaveBeenCalledOnce();
103+
expect(warnSpy).toHaveBeenCalledWith(
104+
expect.stringContaining(`${NO_PROBE_DATA_THRESHOLD} requests handled`),
105+
);
106+
});
107+
108+
it('does not warn if probe is received before threshold', async () => {
109+
await setup();
110+
const warnSpy = vi.mocked(console.warn);
111+
112+
// Send probe first
113+
await fetch(`${baseUrl}/device-router/probe`, {
114+
method: 'POST',
115+
headers: { 'Content-Type': 'application/json' },
116+
body: JSON.stringify({
117+
hardwareConcurrency: 4,
118+
deviceMemory: 4,
119+
connection: { effectiveType: '4g', downlink: 10 },
120+
}),
121+
});
122+
123+
for (let i = 0; i < NO_PROBE_DATA_THRESHOLD; i++) {
124+
await fetch(`${baseUrl}/test`);
125+
}
126+
127+
expect(warnSpy).not.toHaveBeenCalled();
128+
});
129+
130+
it('warns only once', async () => {
131+
await setup();
132+
const warnSpy = vi.mocked(console.warn);
133+
134+
for (let i = 0; i < NO_PROBE_DATA_THRESHOLD * 2; i++) {
135+
await fetch(`${baseUrl}/test`);
136+
}
137+
138+
expect(warnSpy).toHaveBeenCalledOnce();
139+
});
140+
141+
it('is silent in production', async () => {
142+
process.env.NODE_ENV = 'production';
143+
vi.restoreAllMocks();
144+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
145+
const storage = new MemoryStorageAdapter();
146+
147+
app = express();
148+
app.use(cookieParser());
149+
150+
const { middleware } = createDeviceRouter({ storage });
151+
app.use(middleware);
152+
app.get('/test', (_req, res) => res.json({ ok: true }));
153+
154+
await new Promise<void>((resolve) => {
155+
server = app.listen(0, () => resolve());
156+
});
157+
const addr = server.address();
158+
if (typeof addr === 'object' && addr) {
159+
baseUrl = `http://127.0.0.1:${addr.port}`;
160+
}
161+
162+
for (let i = 0; i < NO_PROBE_DATA_THRESHOLD * 2; i++) {
163+
await fetch(`${baseUrl}/test`);
164+
}
165+
166+
expect(warnSpy).not.toHaveBeenCalled();
167+
});
168+
});
169+
170+
describe('Strategy C: onEvent callback', () => {
171+
let server: Server;
172+
173+
afterEach(async () => {
174+
if (server) {
175+
await new Promise<void>((resolve) => server.close(() => resolve()));
176+
}
177+
});
178+
179+
it('emits diagnostic:no-probe-data through onEvent', async () => {
180+
process.env.NODE_ENV = 'development';
181+
vi.spyOn(console, 'info').mockImplementation(() => {});
182+
vi.spyOn(console, 'warn').mockImplementation(() => {});
183+
const onEvent = vi.fn();
184+
const storage = new MemoryStorageAdapter();
185+
186+
const app = express();
187+
app.use(cookieParser());
188+
const { middleware } = createDeviceRouter({ storage, onEvent });
189+
app.use(middleware);
190+
app.get('/test', (_req, res) => res.json({ ok: true }));
191+
192+
await new Promise<void>((resolve) => {
193+
server = app.listen(0, () => resolve());
194+
});
195+
const addr = server.address();
196+
let baseUrl = '';
197+
if (typeof addr === 'object' && addr) {
198+
baseUrl = `http://127.0.0.1:${addr.port}`;
199+
}
200+
201+
for (let i = 0; i < NO_PROBE_DATA_THRESHOLD; i++) {
202+
await fetch(`${baseUrl}/test`);
203+
}
204+
205+
expect(onEvent).toHaveBeenCalledWith(
206+
expect.objectContaining({
207+
type: 'diagnostic:no-probe-data',
208+
middlewareInvocations: NO_PROBE_DATA_THRESHOLD,
209+
probePath: '/device-router/probe',
210+
}),
211+
);
212+
});
213+
});
214+
});

packages/middleware-express/src/index.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createRequire } from 'node:module';
22
import { readFileSync } from 'node:fs';
33
import type { StorageAdapter } from '@device-router/storage';
44
import type { TierThresholds, FallbackProfile, OnEventCallback } from '@device-router/types';
5-
import { validateThresholds } from '@device-router/types';
5+
import { validateThresholds, createProbeHealthCheck } from '@device-router/types';
66
import { createMiddleware } from './middleware.js';
77
import { createProbeEndpoint } from './endpoint.js';
88
import { createInjectionMiddleware } from './inject.js';
@@ -42,28 +42,53 @@ export function createDeviceRouter(options: DeviceRouterOptions) {
4242

4343
if (thresholds) validateThresholds(thresholds);
4444

45+
const isNonProd = process.env.NODE_ENV !== 'production';
46+
const effectiveProbePath = probePath ?? '/device-router/probe';
47+
48+
if (isNonProd) {
49+
console.info(`[DeviceRouter] Probe endpoint expected at POST ${effectiveProbePath}`);
50+
}
51+
52+
const health = isNonProd
53+
? createProbeHealthCheck({ onEvent, probePath: effectiveProbePath })
54+
: null;
55+
56+
const rawMiddleware = createMiddleware({
57+
storage,
58+
cookieName,
59+
thresholds,
60+
fallbackProfile,
61+
classifyFromHeaders,
62+
onEvent,
63+
});
64+
65+
const rawEndpoint = createProbeEndpoint({
66+
storage,
67+
cookieName,
68+
cookiePath,
69+
cookieSecure,
70+
ttl,
71+
rejectBots,
72+
onEvent,
73+
});
74+
4575
const result: {
4676
middleware: ReturnType<typeof createMiddleware>;
4777
probeEndpoint: ReturnType<typeof createProbeEndpoint>;
4878
injectionMiddleware?: ReturnType<typeof createInjectionMiddleware>;
4979
} = {
50-
middleware: createMiddleware({
51-
storage,
52-
cookieName,
53-
thresholds,
54-
fallbackProfile,
55-
classifyFromHeaders,
56-
onEvent,
57-
}),
58-
probeEndpoint: createProbeEndpoint({
59-
storage,
60-
cookieName,
61-
cookiePath,
62-
cookieSecure,
63-
ttl,
64-
rejectBots,
65-
onEvent,
66-
}),
80+
middleware: health
81+
? async (req, res, next) => {
82+
health.onMiddlewareHit();
83+
return rawMiddleware(req, res, next);
84+
}
85+
: rawMiddleware,
86+
probeEndpoint: health
87+
? async (req, res) => {
88+
health.onProbeReceived();
89+
return rawEndpoint(req, res);
90+
}
91+
: rawEndpoint,
6792
};
6893

6994
if (injectProbe) {

0 commit comments

Comments
 (0)