@rodrigopsasaki/vision
v0.5.0
Published
Canonical context and structured observability for Node.js
Maintainers
Readme
vision
Structured observability, modeled around intent — not output.
You don't need more logs, you need context.
You need to know:
- What just happened
- What data was involved
- What the outcome was
- Why it matters
But most systems log like this:
Three stages of observability
Let's say you're processing a shopping cart.
1. Naive logging
console.log("starting cart job");
console.log("loaded cart", cart.id);
console.log("charging", cart.total);
console.log("done", { status: "ok" });This tells a story — but it's whispering. No IDs. No continuity. Just bursts of text into the void. Now imagine billions of these. Good luck finding the one you care about.
2. Disciplined logging
console.log("start", { user_id, correlation_id });
console.log("loaded cart", { cart_id, correlation_id });
console.log("charging", { amount, correlation_id });
console.log("done", { cart_id, status }); // <-- whoops, no user_id, no correlation_idYou're trying. You're threading a correlation_id.
You're passing user_id everywhere manually.
Until someone forgets. And now that one log line is invisible.
3. Structured context with Vision
await vision.observe("cart.process", async () => {
vision.set("user_id", userId);
vision.set("correlation_id", req.headers["x-correlation-id"]);
const cart = await db.getCart(userId);
vision.set("cart_id", cart.id);
const result = await chargeCard(cart);
vision.set("charge_status", result.status);
await sendConfirmation(userId);
vision.set("confirmation", "sent");
});Now everything lives in a scoped context. No repetition. No missing keys. Just a clean canonical event:
{
"name": "cart.process",
"timestamp": "...",
"data": {
"user_id": "u123",
"correlation_id": "abc-456",
"cart_id": "c789",
"charge_status": "success",
"confirmation": "sent"
}
}Real-world usage
Vision is meant to disappear into your system. Here's what it looks like in real code:
await vision.observe("order.fulfillment", async () => {
vision.set("user_id", user.id);
vision.set("order_id", order.id);
await fulfillOrder(order);
});// fulfillment.ts
import { vision } from "@rodrigopsasaki/vision";
export async function fulfillOrder(order) {
await pickItems(order);
await packItems(order);
await shipOrder(order);
}
async function pickItems(order) {
// ...picking logic...
vision.push("events", "picked");
}
async function packItems(order) {
// ...packing logic...
vision.push("events", "packed");
vision.merge("dimensions", { weight: "2.1kg" }); // replaces a log line
}
async function shipOrder(order) {
// ...shipping logic...
vision.push("events", "shipped");
vision.merge("shipment", {
carrier: "DHL",
tracking: "abc123",
});
}You don't pass context around. You don't log manually. You just describe what happened.
Vision collects it — then emits exactly one event.
Install
npm add @rodrigopsasaki/visionQuick start
You don't need to configure anything to start using Vision.
import { vision } from "@rodrigopsasaki/vision";
await vision.observe("my.workflow", async () => {
vision.set("step", "one");
vision.set("status", "ok");
});That's it.
No setup. No boilerplate. No init() call required.
Vision runs with a default console exporter out of the box.
When you're ready to customize behavior — like sending events to Datadog or disabling console logs — you can call vision.init() to register your own exporters.
Working with context
Vision gives you a few simple tools:
vision.set("foo", "bar");
vision.get("foo"); // "bar"
vision.push("tags", "new");
vision.push("tags", "priority");
vision.merge("meta", { version: "1.2.3" });
vision.merge("meta", { region: "us-east-1" });Everything you set is scoped to the active observe() block.
Accessing context outside that block throws — by design.
You don't need a correlation ID
Most systems bolt on correlation_id to make up for lost context.
Vision doesn't lose context in the first place.
You don't thread a request ID.
You don't decorate every log call.
You just enter an observe() block — and Vision handles the scope.
Want to tag with a trace ID from upstream? Do it once:
await vision.observe("http.request", async () => {
vision.set("trace_id", req.headers["x-trace-id"]);
// do work
});You get a unique ID for free. But you won't need it to hold everything together anymore.
Controlling output
By default, Vision logs a single event to the console. But you can register your own exporters.
Add exporters
Exporters are side-effect hooks that run at the end of a context — on success or error.
vision.registerExporter({
name: "datadog",
success: (ctx) => sendToDatadog(ctx),
error: (ctx, err) => sendErrorToDatadog(ctx, err),
});If you skip error(), Vision falls back to success() even on failure — so you'll still get the data.
Customize at startup
You can register exporters when you initialize:
vision.init({
exporters: [
{
name: "stdout",
success: (ctx) => {
console.log("event:", ctx.name, Object.fromEntries(ctx.data.entries()));
},
},
],
});You can also remove exporters later:
vision.unregisterExporter("stdout");For now, the default console exporter can't be removed — but we'll probably support that soon.
Exporter Lifecycle Hooks
Exporters can optionally provide lifecycle hooks to wrap execution with custom logic. These hooks are purpose-built for observability systems.
Lifecycle Hooks Basics
Exporters can use lifecycle hooks to:
- Set up their own context before execution (e.g., Datadog spans, OpenTelemetry traces)
- Clean up or finalize after successful execution
- Handle error-specific cleanup when execution fails
- Still receive the final context data for export
const myExporter: VisionExporter = {
name: "my-exporter",
before: (ctx: VisionContext) => {
// Set up your custom context here
const span = tracer.startSpan(ctx.name);
ctx.data.set("my.span", span);
},
after: (ctx: VisionContext) => {
// Clean up after successful execution
const span = ctx.data.get("my.span") as any;
if (span) {
span.finish();
ctx.data.delete("my.span");
}
},
onError: (ctx: VisionContext, err: unknown) => {
// Handle error-specific cleanup
const span = ctx.data.get("my.span") as any;
if (span) {
span.tags.error = err instanceof Error ? err.message : String(err);
span.finish();
ctx.data.delete("my.span");
}
},
success: (ctx: VisionContext) => {
// Export the final context data
console.log("Success:", ctx);
},
};Lifecycle Hook Execution Order
- Before Phase: All exporter
before()hooks are called in registration order - Execution: The main callback function runs
- After Phase: If successful, all exporter
after()hooks are called in registration order - OnError Phase: If failed, all exporter
onError()hooks are called in registration order - Export: All exporter
success()orerror()functions are called
Real-world Example: Datadog Integration
import { vision } from "@rodrigopsasaki/vision";
const datadogExporter: VisionExporter = {
name: "datadog",
before: (ctx: VisionContext) => {
// Create a Datadog span with vision context info
const span = tracer.startSpan(ctx.name, {
"vision.id": ctx.id,
"vision.scope": ctx.scope || "unknown",
"vision.source": ctx.source || "unknown",
});
// Store span in context for later cleanup
ctx.data.set("datadog.span", span);
},
after: (ctx: VisionContext) => {
// Finish span on successful completion
const span = ctx.data.get("datadog.span") as any;
if (span) {
span.finish();
ctx.data.delete("datadog.span");
}
},
onError: (ctx: VisionContext, err: unknown) => {
// Finish span with error information
const span = ctx.data.get("datadog.span") as any;
if (span) {
span.tags.error = err instanceof Error ? err.message : String(err);
span.finish();
ctx.data.delete("datadog.span");
}
},
success: (ctx: VisionContext) => {
// Send metrics to Datadog
sendMetrics(ctx.name, Object.fromEntries(ctx.data.entries()));
},
error: (ctx: VisionContext, err: unknown) => {
// Send error metrics to Datadog
sendErrorMetrics(ctx.name, err);
},
};
// Register the exporter
vision.init({
exporters: [datadogExporter],
});
// Now every observe() call creates a Datadog span automatically
await vision.observe("order.processing", async () => {
vision.set("user_id", "user123");
// ... work happens ...
});Error Handling
Lifecycle hooks are executed appropriately based on the execution outcome. Error handling is declarative and clear:
const exporter: VisionExporter = {
name: "error-handling",
before: (ctx: VisionContext) => {
console.log("Setting up...");
},
after: (ctx: VisionContext) => {
console.log("Cleaning up after success...");
},
onError: (ctx: VisionContext, err: unknown) => {
console.log("Cleaning up after error:", err);
},
success: (ctx: VisionContext) => {
console.log("Success!");
},
};API Reference
Core Functions
vision.observe(options, callback)
Creates a new vision context and executes the provided callback within it.
Parameters:
options- Either a string (context name) or a full options objectcallback- The async function to execute within the vision context
Returns: Promise that resolves to the callback result
Example:
// Simple usage
await vision.observe("user.login", async () => {
vision.set("user_id", "user123");
// ... work happens ...
});
// Advanced usage
await vision.observe(
{
name: "order.processing",
scope: "http",
source: "api-gateway",
initial: { request_id: "req-123" },
},
async () => {
vision.set("order_id", "order456");
// ... work happens ...
},
);vision.init(options?)
Initializes the vision runtime with custom configuration.
Parameters:
options- Optional runtime configuration
Example:
vision.init({
exporters: [
{
name: "datadog",
success: (ctx) => sendToDatadog(ctx),
error: (ctx, err) => sendErrorToDatadog(ctx, err),
},
],
});Context Manipulation
vision.set(key, value)
Sets a key-value pair in the current vision context.
Parameters:
key- The key to setvalue- The value to store
vision.get(key)
Retrieves a value from the current vision context.
Parameters:
key- The key to retrieve
Returns: The stored value or undefined if not found
vision.push(key, value)
Pushes a value to an array in the current vision context.
Parameters:
key- The key for the arrayvalue- The value to push
vision.merge(key, value)
Merges an object into an existing object in the current vision context.
Parameters:
key- The key for the objectvalue- The object to merge
vision.context()
Gets the current active vision context.
Returns: The current vision context
Throws: Error if called outside of a vision context
Exporter Management
vision.registerExporter(exporter)
Registers a new exporter to receive vision context data.
Parameters:
exporter- The exporter to register
vision.unregisterExporter(name)
Unregisters an exporter by name.
Parameters:
name- The name of the exporter to remove
Philosophy
Vision replaces many logs with one idea:
"This happened. Here's everything we know."
It's not a log formatter. It's a structured, observable boundary.
You stop logging every heartbeat. You start capturing truth.
Requirements
- Node.js 18+ (for
crypto.randomUUID()andAsyncLocalStorage) - TypeScript 5.0+ (for best experience)
License
MIT © Rodrigo Sasaki
