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

@phyxiusjs/observe

v0.2.0

Published

Observability utilities for manipulating context data

Readme

Observe

The accumulator. The piece that lets nested code contribute to a single contextual event without threading observability data through every function signature.


What this really is

Observe is how you write structured data into the current @phyxiusjs/context scope. It pairs with Context and Journal to deliver the "one event per unit of work" pattern: any code under a scope can add to the picture, and the outer handler appends one complete journal entry at the end.

Every field you observe is declared as a typed handle up front. There is no loose string-keyed bag. The schema you declare becomes the sidecar type for the process — readable by humans, readable by LLMs, enforceable by the compiler.

import { observe } from "@phyxiusjs/observe";

// Declare once. This IS the observability schema for the process.
export const fields = observe.fields({
  requestId: observe.field<string>(),
  operation: observe.field<string>(),
  attempts: observe.number(),
  events: observe.array<{ type: string; at: number }>(),
  errors: observe.array<{ message: string; code: string }>(),
});

Use it inside any context.scope:

await context.scope(
  async () => {
    fields.operation.set("payment.charge"); // typed: must be string
    fields.attempts.inc(); // typed: must be numeric field
    fields.events.push({ type: "auth.start", at: clock.now().wallMs });

    await chargeCard();

    fields.attempts.inc();
    fields.events.push({ type: "card.declined", at: clock.now().wallMs });
  },
  { initial: {} },
);

At the end, get a typed snapshot for the journal:

const snap = observe.snapshot(fields);
// snap: Partial<{
//   requestId: string;
//   operation: string;
//   attempts: number;
//   events: {type: string; at: number}[];
//   errors: {message: string; code: string}[];
// }>
journal.append(snap);

Why typed-first

The original design was a loose string-keyed bag — easy to add fields, no declaration overhead. That ergonomics ended at the moment the codebase started assuming shapes it never declared: get("attempts") as number, push("events", x) where x could be anything, silent type coercion when somebody set("events", "oops") before a nested scope pushed.

Explicit schemas fix this at the source:

  • No silent coercion. inc on a non-numeric field throws. push on a non-array field throws. The loose API where "I'll just replace whatever was there" is gone.
  • LLMs reading the code see the schema. A re-imagined handler's observe-fields.ts is the single file that says "here is what this process observes." No grepping for observe.set call sites.
  • Refactor-friendly. Rename a field once in the schema; TS flags every call site.
  • Discoverable. fields. autocompletes to the full observable surface.
  • Derivable shape. InferShape<typeof fields> gives you the plain-object type — use it anywhere you need to consume the accumulated data downstream.

The declaration cost (writing observe.fields({...}) once per process) is a rounding error compared to what you get — and in an LLM-authored codebase, the "cost" is zero.


Composition: Context + Observe + Journal

The three primitives together are the mechanism behind "one event per unit of work":

import { context } from "@phyxiusjs/context";
import { observe, type InferShape } from "@phyxiusjs/observe";
import { Journal } from "@phyxiusjs/journal";

const fields = observe.fields({
  operation: observe.field<string>(),
  attempts: observe.number(),
  spans: observe.array<{ name: string; startedAt: number; durationMs: number }>(),
});

const journal = new Journal<Partial<InferShape<typeof fields>>>({ clock });

async function runRequest() {
  await context.scope(
    async () => {
      fields.operation.set("checkout");

      // Nested operations push into the SAME accumulated trace —
      // this is the core of why Context inherits by shallow copy.
      await validateCart();
      await reservePayment();
      await fulfill();

      journal.append(observe.snapshot(fields));
    },
    { initial: {} },
  );
}

async function validateCart() {
  const started = clock.now().wallMs;
  // ... work ...
  fields.spans.push({
    name: "cart.validate",
    startedAt: started,
    durationMs: clock.now().wallMs - started,
  });
}

Any nested function can contribute. At the end, one journal entry contains the complete story.


API

Declarations

observe.field<T>(): FieldSpec<T>          // .set / .get / .has / .delete
observe.number(): NumericFieldSpec        // + .inc(amount?)
observe.array<T>(): ArrayFieldSpec<T>     // + .push(value)

Field specs are declaration-only — they carry the type but not the key. The key comes from the property name when you resolve the schema.

FieldSpec, NumericFieldSpec, and ArrayFieldSpec are public types so the inferred return type of observe.fields(...) names only public types — important for downstream packages that emit declarations (tsconfig.json's declaration: true) and want to export their own field bags. You'll rarely write the spec types by hand; the constructors above produce them.

Resolution

observe.fields(schema): ResolvedFields<typeof schema>

Takes a record of field specs, returns a record of typed handles keyed by the same property names.

Snapshot

observe.snapshot(fields): Partial<InferShape<typeof fields>>

Reads the current scope's data and returns only the declared fields that have been set. Fully typed — each key has its declared type.

Shape inference

type Shape = InferShape<typeof fields>;

Derives the plain-object shape from a resolved fields bag. Useful for typing journals, downstream consumers, or cross-boundary payloads.

Handle API

interface ObserveField<T> {
  readonly key: string;
  set(value: T): void;
  get(): T | undefined;
  has(): boolean;
  delete(): boolean;
}

interface NumericObserveField extends ObserveField<number> {
  inc(amount?: number): void; // throws if existing value is non-numeric
}

interface ArrayObserveField<T> extends ObserveField<T[]> {
  push(value: T): void; // throws if existing value is non-array
}

Cross-scope accumulation

observe respects Context's shallow inheritance (which is the point — it's how accumulation works):

const fields = observe.fields({
  trace: observe.array<{ span: string; op: string }>(),
});

await context.scope(
  async () => {
    fields.trace.push({ span: "root", op: "login" });

    await context.scope(async () => {
      fields.trace.push({ span: "child", op: "validate" });
    });

    // Parent sees both. This is the mechanism, not a bug.
    const t = fields.trace.get();
    // [{ span: "root", op: "login" }, { span: "child", op: "validate" }]
  },
  { initial: { trace: [] } },
);

When you want scope isolation (rare), pass inherit: false to the inner context.scope(...).


What observe does NOT do

  • No durability. Writing happens in-memory on the active scope's data. The snapshot is a value; shipping it to disk or a pipeline is the journal/drain layer's job.
  • No runtime schema registry. The schema lives in your code as a typed handle bag, not in a separate system. TypeScript is the only check.
  • No cross-process propagation. Everything is local to the current AsyncLocalStorage scope. Tracing across services needs transport-level propagation.
  • No implicit fields. If it's not declared, you can't observe it. Missing-by-design.

Installation

npm install @phyxiusjs/observe @phyxiusjs/context

What you get

  • Sidecar types. Your schema file is the observability surface, readable by humans and LLMs, checked by the compiler.
  • Accumulation that composes. Nested scopes contribute to the same trace; the outer handler gets the complete story.
  • Failure modes structurally impossible. Can't set a string to a numeric field. Can't push to a non-array. Can't silently clobber data by forgetting the shape.
  • Zero runtime overhead. Thin wrappers over ctx.data reads and writes. The types live at compile time.

Observe is the small primitive that made the composition click. Once you have Context flowing data, Observe declaring the shape, and Journal holding the entries, you don't need middleware, handlers-as-classes, or framework ceremony. You just need to know what you want to observe — and now the compiler knows too.