
Security News
Attackers Are Hunting High-Impact Node.js Maintainers in a Coordinated Social Engineering Campaign
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.
canon-observability
Advanced tools
One request = one canonical wide event. Production observability with schema validation, tail sampling, and PII redaction.
One request = one canonical wide event.
Canon is an opinionated observability library for Node.js that guarantees exactly one structured wide event per HTTP request in Express. The event is enriched incrementally via req.canon and emitted once when the request completes (finish) or is aborted (close).
Canon enforces a simple rule: one request = one canonical wide event. Instead of scattered log lines across your codebase, Canon builds a single structured event that captures all request context, business metrics, and outcomes.
{
"timestamp": "2024-01-15T10:30:00.000Z",
"request_id": "req_abc123",
"trace_id": "trace_xyz789",
"service": "checkout-service",
"version": "1.0.0",
"method": "POST",
"path": "/checkout",
"status_code": 200,
"duration_ms": 150,
"outcome": "success",
"user": {
"id": "u_123",
"email": "j***@e***.com"
},
"cart": {
"item_count": 3,
"total_cents": 9999
},
"payment": {
"provider": "stripe",
"latency_ms": 89,
"success": true
}
}
Canon v0 is intentionally limited and boring:
This keeps Canon focused, reliable, and easy to reason about. Boring is good.
Not in v0:
See the Roadmap for planned features.
Traditional logging creates multiple log lines per request:
logger.info('Request started', { path: '/checkout' });
logger.info('User authenticated', { userId: 'u123' });
logger.info('Payment processed', { amount: 9999 });
logger.info('Request completed', { duration: 150 });
This leads to:
Canon solves this by:
pnpm add canon
import express from 'express';
import { canonExpress, canonExpressError, defineCanonSchema } from 'canon';
const app = express();
app.use(express.json());
const schema = defineCanonSchema({
required: ['user.id'],
fields: {
'user.id': { type: 'string' },
'user.email': { type: 'string', pii: true },
},
});
app.use(canonExpress({
service: 'my-service',
version: '1.0.0',
schema,
redact: {
enabled: true,
strategy: 'mask',
fields: ['user.email'],
},
}));
app.get('/user/:id', (req, res) => {
req.canon.enrich({
user: {
id: req.params.id,
email: 'user@example.com',
},
});
res.json({ success: true });
});
app.use(canonExpressError());
Run the example:
pnpm tsx examples/express-basic.ts
curl http://localhost:3000/user/123
Merge nested objects into the event:
req.canon.enrich({
user: {
id: 'u_123',
subscription: 'enterprise',
},
cart: {
item_count: 3,
total_cents: 9999,
},
});
Set a value at a dot-separated path:
req.canon.set('payment.provider', 'stripe');
req.canon.set('payment.latency_ms', 150);
req.canon.set('error.code', 'PAYMENT_DECLINED');
Capture errors (usually handled by canonExpressError()):
try {
await processPayment();
} catch (err) {
req.canon.markError(err);
throw err;
}
Define your event schema with defineCanonSchema():
const schema = defineCanonSchema({
required: ['user.id', 'cart.total_cents'],
fields: {
'user.id': { type: 'string' },
'user.email': { type: 'string', pii: true },
'cart.total_cents': { type: 'number' },
},
unknownMode: 'warn', // 'allow' | 'warn' | 'deny'
});
Canon automatically validates these built-in required fields:
timestamp, request_id, service, method, path, status_code, duration_ms, outcomeAdd your own required fields in schema.required (supports dot-paths).
Controls how unknown top-level fields are handled:
allow (default): Accept silentlywarn: Accept but log warning to stderrdeny: Reject in strict mode, warn in non-strictNote: unknownMode only applies to top-level keys in v0. Nested unknown fields are always allowed.
Tip: To avoid warnings with unknownMode: 'warn', either:
unknownMode: 'allow' (recommended for v0)Protect PII with three strategies:
redact: {
enabled: true,
strategy: 'mask', // 'mask' | 'hash' | 'drop'
fields: ['user.email', 'headers.authorization'],
}
| Strategy | Example Input | Example Output |
|---|---|---|
mask | john@example.com | j***@e***.com |
hash | john@example.com | a1b2c3d4... (SHA-256 hex) |
drop | john@example.com | [REDACTED] |
Schema-level redaction strategies override the global config:
const schema = defineCanonSchema({
fields: {
'user.email': { type: 'string', pii: true, redaction: 'hash' },
'user.phone': { type: 'string', pii: true },
},
});
app.use(canonExpress({
schema,
redact: {
enabled: true,
strategy: 'mask', // default for fields without schema redaction
fields: ['user.email', 'user.phone'],
},
}));
// Result: user.email is hashed, user.phone is masked
Important: Redaction happens on a copy of the event. The original context in handlers remains unredacted.
If fields is empty, Canon redacts these by default:
user.email, user.phone, headers.authorization, headers.cookie, headers.x-api-keyMake sampling decisions after request completion with full context:
sample: {
sampleRateSuccess: 0.05, // 5% of successful requests
slowThresholdMs: 2000, // Always sample slow requests (>2s)
}
// Or use a custom function
sample: (event) => {
if (event.status_code >= 500) return true;
if (event.outcome === 'aborted') return true;
if (event.duration_ms > 2000) return true;
if (event.user?.subscription === 'enterprise') return true;
return Math.random() < 0.05;
}
Without custom configuration, Canon always samples:
outcome === 'aborted' (client disconnects)outcome === 'error' (any error)duration_ms > 2000 (slow requests)And rate samples:
sampleRateSuccess: 0.05)Canon listens to both res.finish and res.close events:
finish: Normal response completion
outcome = 'success' if no error, else 'error'status_code = res.statusCodeclose: Client disconnect/abort (only if response not ended)
outcome = 'aborted'status_code = 499 (forced)The emit-once guard ensures only one event is emitted, even if both events fire.
By default, Canon writes JSON lines to stdout:
{"timestamp":"2024-01-15T10:30:00.000Z","request_id":"req_abc123",...}
In production, stdout is typically captured by:
docker logs <container>kubectl logs <pod>Your infrastructure then ships these logs to your observability backend. Canon doesn't ship logs directly - it just writes to stdout.
Override the default emit to send events elsewhere:
emit: (event) => {
// Send to your log aggregation service
logService.send(event);
// Or write to a file
fs.appendFileSync('events.jsonl', JSON.stringify(event) + '\n');
}
| Option | Type | Default | Description |
|---|---|---|---|
service | string | required | Service name |
version | string | - | Service version |
deployment_id | string | - | Deployment identifier |
region | string | - | Deployment region |
schema | CanonSchema | - | Event schema definition |
requestIdHeader | string | 'x-request-id' | Header name for request ID |
traceIdHeader | string | 'x-trace-id' | Header name for trace ID |
trustIncomingIds | boolean | true | Trust incoming request/trace IDs |
emit | (event) => void | stdout JSON | Custom emit function |
strict | boolean | false | Treat validation warnings as errors |
sample | SamplingConfig | function | default rules | Sampling configuration |
redact | RedactionConfig | disabled | PII redaction configuration |
{
sampleRateSuccess?: number; // Default: 0.05 (5%)
slowThresholdMs?: number; // Default: 2000ms
custom?: (event) => boolean; // Custom sampling function
}
{
enabled: boolean; // Default: false
strategy: 'mask' | 'hash' | 'drop'; // Default: 'mask'
fields: string[]; // Dot-path fields to redact
}
Canon provides full TypeScript support:
import type { WideEvent, CanonConfig, CanonSchema } from 'canon';
// req.canon is fully typed
req.canon.enrich({ user: { id: '123' } });
req.canon.set('payment.provider', 'stripe');
// Express Request typing is augmented automatically
The req.canon property is added to Express Request types via module augmentation.
This happens when unknownMode: 'warn' is set and you add fields not defined in your schema. Solutions:
unknownMode: 'allow' (recommended for v0)outcome set to 'aborted'?This happens when the client disconnects before the response completes. Canon detects this via the res.close event and sets outcome: 'aborted' with status_code: 499.
status_code 499?Status code 499 (Client Closed Request) is set when outcome === 'aborted'. This is a non-standard status code used by Canon to indicate client disconnects.
Check:
res.finish / res.close events)sample config)canonExpressError() registered after all routes?Use a custom emit function:
emit: (event) => {
console.log(JSON.stringify(event, null, 2));
}
# Install dependencies
pnpm install
# Run tests
pnpm test
# Run examples
pnpm tsx examples/express-basic.ts
pnpm tsx examples/express-checkout.ts
# Test endpoints
curl http://localhost:3000/hello
curl http://localhost:3001/checkout -X POST -H "Content-Type: application/json" -d '{"cart_id":"cart_1"}'
Note: In-repo examples import from ../src/index for development. Published usage imports from 'canon'.
{
"timestamp": "2024-01-15T10:30:00.000Z",
"request_id": "req_abc123",
"trace_id": "trace_xyz789",
"service": "checkout-service",
"version": "1.0.0",
"method": "POST",
"path": "/checkout",
"status_code": 200,
"duration_ms": 150,
"outcome": "success",
"user": {
"id": "u_123",
"email": "j***@e***.com"
},
"cart": {
"item_count": 3,
"total_cents": 9999
}
}
{
"timestamp": "2024-01-15T10:30:00.000Z",
"request_id": "req_def456",
"service": "checkout-service",
"method": "POST",
"path": "/checkout",
"status_code": 500,
"duration_ms": 89,
"outcome": "error",
"error": {
"type": "Error",
"message": "Payment gateway timeout",
"code": "GATEWAY_TIMEOUT",
"retriable": true
}
}
{
"timestamp": "2024-01-15T10:30:00.000Z",
"request_id": "req_ghi789",
"service": "checkout-service",
"method": "GET",
"path": "/slow",
"status_code": 499,
"duration_ms": 500,
"outcome": "aborted"
}
Planned features (not in v0):
See docs/roadmap.md for details.
MIT
Contributions welcome! Please open an issue or PR.
FAQs
One request = one canonical wide event. Production observability with schema validation, tail sampling, and PII redaction.
The npm package canon-observability receives a total of 1 weekly downloads. As such, canon-observability popularity was classified as not popular.
We found that canon-observability demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.

Security News
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.

Security News
Node.js has paused its bug bounty program after funding ended, removing payouts for vulnerability reports but keeping its security process unchanged.