Arrives with digline 1.1. No earlier release has
digline recordor the files below.
Either way the files written are the same, and so is everything downstream:
digline record import, compare, the page, a run. What a file holds is
the recording document. The commands, digline record
on, status and import, are digline's command line, which is Python:
Install the digline CLI says how
to install it apart from a project that is not Python.
In the process: the files to copy¶
Copy recorders/node/digline/ from digline's repository into your project,
as a directory named digline. Its four files need Node 22 or later and no
package: recorder.mjs, the writer, messages.mjs, which reads Anthropic's
Messages for the two hooks that meet it, and the two hooks:
| Your client | The hook | How it is installed |
|---|---|---|
openai |
diglineFetch(recorder), in fetch.mjs |
the client's fetch option |
@anthropic-ai/sdk |
diglineFetch(recorder), in fetch.mjs |
the client's fetch option |
@aws-sdk/client-bedrock-runtime |
diglineAws(recorder), in aws.mjs |
middlewareStack.use(...) on the client |
Every line of JavaScript in this part is a line of
recorders/node/page.test.mjs, which the CI job recorder-node runs on every
pull request against a fake provider on 127.0.0.1, with openai 7.50.0,
@anthropic-ai/sdk 1.4.0 and @aws-sdk/client-bedrock-runtime 3.1163.0. A test
holds every block here to that file. The job also holds the writer to the
fixtures digline's own Python makes, the ones Java's recorder is held to, and
imports what it wrote with digline alone. The CI job through-the-proxy sends
the request it sends from every language through diglineFetch too, and holds
that it gives the same case and setup digest as through the proxy.
import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk";
import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
import { DiglineRecorder, attach, suite } from "./digline/recorder.mjs";
import { diglineFetch } from "./digline/fetch.mjs";
import { diglineAws } from "./digline/aws.mjs";
The recorder is made once, for the application, with the directory that holds
.digline/, the tenant and the application's name, and each client takes the
hook:
const recorder = DiglineRecorder.development(root, "acme", "support");
const openai = new OpenAI({ fetch: diglineFetch(recorder) });
const anthropic = new Anthropic({ fetch: diglineFetch(recorder) });
const bedrock = new BedrockRuntimeClient({});
bedrock.middlewareStack.use(diglineAws(recorder));
The hook is the fetch the client is given, not the global one. A client
built before the global fetch was replaced keeps the one it read when it was
built: measured with openai's client on 2026-11-06 (ADR 0062 §4, as amended
that day), and @anthropic-ai/sdk's code reads it the same way, at
construction. So a client that should be recorded is given the option.
A call names its suite, the same name as that suite's Suite(name=...), for
what the function given to suite runs and awaits:
A call that names none is recorded under _unassigned, with the file, relative
to the root, and the function it was made from. A function that returns the
SDK's promise without await is not on the stack when the call is made, so
the call carries the function that awaited it instead: return await names
the function itself.
It records nothing until the tenant's switch says so, the file
digline record on writes, which the writer reads
with digline's own rules and reads again at its interval. Without the file the
recorder is off, and so it is where the root holds no .digline/. In
development DIGLINE_RECORD=1 switches it on where no file exists.
A turn of an agent¶
An episode keeps one turn as one recording, with the calls made inside it as its steps, written when the turn's function ends. A turn that throws is recorded interrupted, and the error goes on to the caller:
await recorder.episode("order-agent", { ticket }, async (episode) => {
episode.output(await agent.answer(ticket));
});
The episode names its own suite, and not its calls': each call names one, or
is recorded under _unassigned. A call made by work the turn's function does
not run itself is put in the episode by its id, which is null while the
recorder is off:
In production¶
production takes a function asked at every call, and each tenant's
recordings are filed under its own directory. It refuses to start, with a
StartError, where a tenant's file is invalid, or switches recording on
without redact, sample and ceiling, as digline record on --production
writes them. It also refuses where DIGLINE_RECORD=1 is set:
What the hooks read¶
diglineFetchreads Chat Completions and Messages, single and streamed, by the request's path. Any other request goes through untouched and is not counted.diglineAwsreads Converse, and InvokeModel with an Anthropic model, whose body is Anthropic's Messages, read as a direct call to Anthropic is. InvokeModel with another family, Titan, Llama or Mistral, is not recorded, and neither is an embedding model: each call is counted inrecorder.counts()with its reason, model family not recorded or embeddings not recorded, and standard error says it once a family. The family is read from the model id, a cross-region prefix (us.,eu.,apac.,global.) taken off first.- A reply is read only where its status is 2xx. A retry the SDK makes after an error is one call, recorded once.
- A stream is recorded when the application has read it to its end. One it
stops reading before is counted stream abandoned. OpenAI's SDK stops at
[DONE], and Anthropic's atmessage_stop: either counts as the end.
Through the proxy¶
On the machine that runs the application, in the directory that holds its
.digline/:
What it prints, the routes and the addresses, and what it does and does not do, are in Through the proxy.
The proxy's machine needs digline and its plugins; the application needs
neither. The proxy reads each provider's calls with that provider's plugin,
pip install digline digline-openai digline-anthropic, and a route whose
plugin is not installed is refused, naming the package. Nothing is installed in
the Node application: it keeps its own SDKs and points them at the proxy.
Three lines¶
Each line below is a line of recorders/node/through-the-proxy.mjs, which
the CI job through-the-proxy runs on every pull request, with the versions
above: a single call and a stream from each, through the proxy to a fake
provider. A test holds every line quoted here to that file. What the job
measures is that the proxy records Node's calls with the same case and the
same setup digest as the same request sent from Python, and, for OpenAI's,
from LangChain4j in Java.
1. The base URL. Either the SDK's own variable, with no change to the code, which the job sets for Anthropic's client:
or the client's option, baseURL, which the job gives OpenAI's:
OpenAI's ends in /v1, and so does its variable, OPENAI_BASE_URL, which
the start of the proxy prints; Anthropic's is the address alone, and the SDK
adds /v1/messages itself. OPENAI_BASE_URL is read by the SDK, as ADR
0062's table measured on 2026-11-04, and the job does not run it. The key
stays the application's own, in its own headers, and the proxy never reads,
logs or writes it.
2. The suite, a header on every call, through defaultHeaders:
X-Digline-App is optional, and names the app the call is filed under,
proxy otherwise. Neither header reaches the provider. A call that names no
suite is recorded under _unassigned, with the SDK's User-Agent and the
path where a call in Python names its function, and
digline record import --from _unassigned lists those calls as a suite's.
3. The episode, where a turn of an agent makes several calls: the same
X-Digline-Episode on each, as a request's own headers:
const turn = { headers: { "X-Digline-Episode": "turn-node" } };
await openai.chat.completions.create(asked.openai, turn);
Any value the application chooses, one per turn. The proxy writes the turn
as an episode once no call has carried it for 30 seconds, or when it stops:
its answer is the last call's text, its case the first call's messages or the
JSON object X-Digline-Case gives, and each tool step is closed by the
result the next call sends back to the model.
Then the recordings are where every recording is, and
digline record import
lists them.
What stays out¶
- Bedrock's streams.
converseStreamandinvokeModelWithResponseStreampass throughdiglineAwsunrecorded, and each is counted stream not recorded inrecorder.counts(), until the reading of AWS's event stream lands. The SDK reads them as it would without the hook. - HTTP/2 to AWS. The Bedrock client speaks HTTP/2 by default, and the
page's lines were run that way, but against the fake provider on
127.0.0.1, not against AWS. The hook sits above the transport and reads the bytes the SDK reads. - Bedrock through the proxy from Node is not measured: the proxy records Bedrock, and the job sends it no call from Node.
- The counts. The writer keeps no counts file, which is not part of the
contract.
digline record statussays not counted for its process, andrecorder.counts()gives the same counts in memory. - A worker thread is not measured: what
suite,episodeandattachhold is held byAsyncLocalStorage, and whether it reaches a call made in aWorkerwas not tried. - A framework over the SDKs, LangChain.js or the Vercel AI SDK, is not
measured. One that hands the provider's SDK a
fetch, or a base URL and headers for the proxy, should be read the same way, and that is a sentence nobody has run. - Another version of any of the three SDKs is what Dependabot proposes, and the jobs say on that pull request whether the hooks and the proxy still record what it sends.