npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

canon-observability

v0.1.1

Published

One request = one canonical wide event. Production observability with schema validation, tail sampling, and PII redaction.

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 canon

Quickstart

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/123

Usage 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 silently
  • warn: Accept but log warning to stderr
  • deny: 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 masked

Important: 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 completion

    • outcome = '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:

  1. Docker logs - docker logs <container>
  2. Kubernetes - kubectl logs <pod>
  3. Log shippers - Fluentd, Fluent Bit, Logstash
  4. 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 automatically

The 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:

  1. Set unknownMode: 'allow' (recommended for v0)
  2. Define all top-level fields you use in your schema
  3. 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:

  1. Is the request completing? (check res.finish / res.close events)
  2. Is sampling filtering it out? (check your sample config)
  3. Is validation failing in strict mode? (check stderr for validation errors)
  4. 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.