canon-observability
v0.1.1
Published
One request = one canonical wide event. Production observability with schema validation, tail sampling, and PII redaction.
Maintainers
Readme
Canon
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).
What is Canon?
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
}
}v0 Scope
Canon v0 is intentionally limited and boring:
- ✅ Express only - No Fastify, no Next.js middleware, no other frameworks
- ✅ Node.js only - No browser support, no edge runtimes
- ✅ Explicit API - No AsyncLocalStorage, no auto-magic, no hidden context
- ✅ One event per request - That's it. Simple and predictable.
This keeps Canon focused, reliable, and easy to reason about. Boring is good.
Not in v0:
- ❌ Fastify support
- ❌ Next.js middleware
- ❌ AsyncLocalStorage / automatic context propagation
- ❌ Auto-magic features
- ❌ Base schema merging (planned for future release)
See the Roadmap for planned features.
The Problem Canon Solves
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:
- Multiple log lines per request - hard to correlate and analyze
- Inconsistent field shapes - different logs have different structures
- No guarantees - might miss emissions on errors or client aborts
- PII leakage - sensitive data scattered across logs
- High cardinality - difficult to sample intelligently
Canon solves this by:
- Exactly one event per request - guaranteed via emit-once guard
- Wide event structure - all context in a single JSON object
- Incremental enrichment - build events throughout request lifecycle
- Schema validation - enforce structure and catch issues early
- PII redaction - protect sensitive data before emission
- Tail sampling - make sampling decisions after request completion
Install
pnpm add canonQuickstart
Express
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: '[email protected]',
},
});
res.json({ success: true });
});
app.use(canonExpressError());Run the example:
pnpm tsx examples/express-basic.ts
curl http://localhost:3000/user/123Usage Patterns
Enrich
Merge nested objects into the event:
req.canon.enrich({
user: {
id: 'u_123',
subscription: 'enterprise',
},
cart: {
item_count: 3,
total_cents: 9999,
},
});Set
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');Mark Error
Capture errors (usually handled by canonExpressError()):
try {
await processPayment();
} catch (err) {
req.canon.markError(err);
throw err;
}Schema Validation
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'
});Required Fields
Canon automatically validates these built-in required fields:
timestamp,request_id,service,method,path,status_code,duration_ms,outcome
Add your own required fields in schema.required (supports dot-paths).
Unknown Mode
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-strict
Note: unknownMode only applies to top-level keys in v0. Nested unknown fields are always allowed.
Tip: To avoid warnings with unknownMode: 'warn', either:
- Set
unknownMode: 'allow'(recommended for v0) - Define all top-level fields you use in your schema
- Base schema merging is planned for a future release
Redaction
Protect PII with three strategies:
redact: {
enabled: true,
strategy: 'mask', // 'mask' | 'hash' | 'drop'
fields: ['user.email', 'headers.authorization'],
}| Strategy | Example Input | Example Output |
|----------|---------------|----------------|
| mask | [email protected] | j***@e***.com |
| hash | [email protected] | a1b2c3d4... (SHA-256 hex) |
| drop | [email protected] | [REDACTED] |
Schema-Level Redaction
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 maskedImportant: Redaction happens on a copy of the event. The original context in handlers remains unredacted.
Default Redacted Fields
If fields is empty, Canon redacts these by default:
user.email,user.phone,headers.authorization,headers.cookie,headers.x-api-key
Tail Sampling
Make 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;
}Default Sampling Behavior
Without custom configuration, Canon always samples:
- Status codes >= 500 (server errors)
- Status codes 429, 408 (rate limit, timeout)
outcome === 'aborted'(client disconnects)outcome === 'error'(any error)duration_ms > 2000(slow requests)
And rate samples:
- 5% of successful requests (
sampleRateSuccess: 0.05)
Abort Handling
Canon listens to both res.finish and res.close events:
finish: Normal response completionoutcome = 'success'if no error, else'error'status_code = res.statusCode
close: 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.
Where Do the Logs Appear?
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 -
docker logs <container> - Kubernetes -
kubectl logs <pod> - Log shippers - Fluentd, Fluent Bit, Logstash
- Observability platforms - Loki, ELK, Datadog, New Relic
Your infrastructure then ships these logs to your observability backend. Canon doesn't ship logs directly - it just writes to stdout.
Custom Emit Function
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');
}Configuration Reference
| 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 |
SamplingConfig
{
sampleRateSuccess?: number; // Default: 0.05 (5%)
slowThresholdMs?: number; // Default: 2000ms
custom?: (event) => boolean; // Custom sampling function
}RedactionConfig
{
enabled: boolean; // Default: false
strategy: 'mask' | 'hash' | 'drop'; // Default: 'mask'
fields: string[]; // Dot-path fields to redact
}TypeScript Notes
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 automaticallyThe req.canon property is added to Express Request types via module augmentation.
FAQ / Troubleshooting
Why am I seeing "unknown top-level field" warnings?
This happens when unknownMode: 'warn' is set and you add fields not defined in your schema. Solutions:
- Set
unknownMode: 'allow'(recommended for v0) - Define all top-level fields you use in your schema
- Base schema merging is planned for a future release
Why is 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.
Why is 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.
Why isn't my event being emitted?
Check:
- Is the request completing? (check
res.finish/res.closeevents) - Is sampling filtering it out? (check your
sampleconfig) - Is validation failing in strict mode? (check stderr for validation errors)
- Is
canonExpressError()registered after all routes?
How do I see events during development?
Use a custom emit function:
emit: (event) => {
console.log(JSON.stringify(event, null, 2));
}Local Development
# 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'.
Sample Events
Success Event
{
"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
}
}Error Event
{
"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
}
}Aborted Event
{
"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"
}Roadmap
Planned features (not in v0):
- Base schema merging - Automatically merge Canon base fields into user schemas
- Next.js middleware support - Native Next.js integration (not in v0)
- Fastify support - Fastify middleware integration (not in v0)
- Node.js HTTP support - Support for native Node.js HTTP server (not in v0)
- OpenTelemetry hook - Enhanced OTel integration with automatic span attributes
See docs/roadmap.md for details.
License
MIT
Contributing
Contributions welcome! Please open an issue or PR.
