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

@introspection-sdk/introspection-node

v0.45.0

Published

Introspection observability SDK for Node.js

Readme

@introspection-sdk/introspection-node

Node.js platform SDK for Introspection — open runtimes, drive tasks, and manage experiments, recipes, repositories, members, automations, issues, app connections, files, conversations, shares, project administration (API keys, Applications, credentials, variables, integrations, judges) and eval images and task commands.

Install

pnpm add @introspection-sdk/introspection-node

For OTel features (analytics, traces, instrumentors), also install the peer dependencies:

pnpm add @opentelemetry/api @opentelemetry/api-logs \
  @opentelemetry/sdk-trace-base @opentelemetry/sdk-trace-node \
  @opentelemetry/sdk-logs @opentelemetry/exporter-trace-otlp-proto \
  @opentelemetry/exporter-logs-otlp-proto @opentelemetry/resources \
  @opentelemetry/semantic-conventions @opentelemetry/context-async-hooks \
  @opentelemetry/core

Introspection API (runtimes, tasks, files)

The main Introspection API surface. No OTel packages required.

import { IntrospectionClient } from "@introspection-sdk/introspection-node";

const client = new IntrospectionClient();

const runner = await client.runtimes("customer-agent").run({
  agent_name: "support-agent",
  scope: "tasks:read tasks:write files:read files:write",
});

const run = await runner.tasks.start({
  prompt: "Say hello in one sentence.",
});

for await (const event of run.stream()) {
  console.log(event.type);
}

await runner.close();
await client.shutdown();

Runner creation also accepts identity, caller, and ttl_seconds, and the resolved runner.context exposes the current runtime or experiment context. run.cancel() aborts by default. Pass { mode: "abort" } to make that explicit, or { mode: "drain", drain_within_seconds: 60 } for graceful teardown. Interrupted runs resume through runner.tasks.runs.resume(taskId, { resume: entries }).

identity.metadata sets key: value labels on the customer member the identity names. It grants nothing, so unlike identity.tags (which seed a new member only) it merges into an existing member too: asserted keys overwrite keys of the same name and the rest are kept. Absent or {} changes nothing.

const runner = await client.runtimes("customer-agent").run({
  identity: { user_id: "u_42", metadata: { plan: "enterprise" } },
});

Data Plane resources: client and runner

IntrospectionClient and Runner expose the same Data Plane namespaces, and both implement the exported DataPlaneResources interface: tasks (with tasks.runs and tasks.commands), files, conversations, events, metrics, shares, automations, issues, images and connections. So does the DataPlaneClient that AuthClient.dataPlane() returns. Code written against DataPlaneResources runs against any of them.

import type { DataPlaneResources } from "@introspection-sdk/introspection-node";

async function connectedApps(dp: DataPlaneResources) {
  return (await dp.connections.list()).records.map((c) => c.app);
}

await connectedApps(client); // the client's own token, on its Data Plane
await connectedApps(runner); // the runner's token, on the runner's Data Plane

The difference is the credential. A runner sends its session token, scoped to the runtime and identity it was opened for; the client sends its own token, so the server decides per call what that credential may do. A runner a member opens for themself carries automations:read and automations:write, so runner.automations manages that member's automations (a platform change in progress; until it ships those routes refuse a runner with a 403).

Members

client.members lists, reads, creates and updates members on the Control Plane's /v1/members; removing one is an org-admin action left to the CLI. A member's tags are access-bearing (it receives every share whose granted_tag it holds) and writable only with members:manage. A member can also still read and write any file or task whose tags intersect its own, but that implicit tag access is deprecated: share with a cohort through a tag share instead. Its metadata is a map of string labels that grants nothing; keys are [A-Za-z0-9_-]+, values are non-empty strings, at most 64 entries.

const ada = await client.members.create({
  email: "[email protected]",
  name: "Ada Lovelace",
  metadata: { plan: "enterprise" },
});

// `tag` and `metadata` narrow the list; metadata pairs are ANDed (at most 16).
for await (const member of client.members.list({
  member_type: "customer",
  metadata: { plan: "enterprise", region: "eu" },
})) {
  console.log(member.id, member.metadata);
}

// PATCH replaces `tags` and `metadata` wholesale; `{}` clears metadata.
await client.members.update(ada.id, { metadata: { plan: "team" } });

Seeding tags on create needs members:manage as well as members:write.

Automations

client.automations (and dataPlaneClient.automations) lists, reads, creates, updates and deletes the project's automations on the Data Plane's /v1/automations, and trigger(id) runs one now. Reading needs automations:read and writing automations:write; each record's can_manage says whether the caller may change it. An automation is a scheduled prompt (kind: null) or platform work (observation_synthesis, observation_clustering, project_check_in). A cron automation fires on cron_schedule, or on metadata.cron_schedules in metadata.timezone; a one-off reminder is a manual automation with a future next_trigger_at. Set task_id to post each firing into an existing task instead of creating one.

The server serves these routes to administrators only today (a 403 otherwise). introspection-cloud#3137, not yet released, opens them to members for their own automations that post into one of their tasks; until it ships, the task_id list filter is ignored by the server.

const reminder = await client.automations.create({
  name: "Friday check-in",
  trigger_type: "manual",
  prompt: "How did the week go?",
  runtime_group_id: runtimeGroupId,
  task_id: taskId,
  next_trigger_at: "2026-10-09T09:00:00Z",
});

// Recurring: one or more cron schedules in a timezone.
await client.automations.create({
  name: "Weekday digest",
  trigger_type: "cron",
  cron_schedule: "0 9 * * 1-5",
  metadata: {
    cron_schedules: ["0 9 * * 1-5"],
    timezone: "America/Los_Angeles",
  },
  prompt: "Summarize yesterday's conversations.",
  runtime_group_id: runtimeGroupId,
});

// PATCH sends only the fields you set; `metadata` replaces wholesale.
await client.automations.update(reminder.id, { enabled: false });

for await (const automation of client.automations.list({ task_id: taskId })) {
  console.log(automation.id, automation.next_trigger_at, automation.can_manage);
}

const { status, task_id } = await client.automations.trigger(reminder.id);

A firing clears a manual automation's slot; re-arm it with update(id, { next_trigger_at }). A cron automation derives its own slot and rejects one set by the client.

Each task-run trigger is recorded as an introspection.automation.triggered event, and a scheduled slot that ran nothing as introspection.automation.skipped (with a reason such as conditions_not_met or target_busy; readable only with project-wide telemetry access). Read them with client.events.list, filtered by automation_id or task_id:

const page = await client.events.list({
  event_name: "introspection.automation.triggered",
  automation_id: reminder.id,
});
for (const ev of page.records)
  console.log(ev.payload.task_id, ev.payload.posted);

Issues

client.issues (and runner.issues, dataPlaneClient.issues) lists, reads, creates, updates and deletes issues on the Data Plane's /v1/issues. An issue is a brief on a fixed worker task: a title, a description, a priority and a status, the evidence it cites (files, links, events and spans) and its open human requests. Reading needs issues:read, creating and updating issues:write, and deleting issues:delete.

const issue = await client.issues.create({
  task_id: taskId,
  title: "Checkout times out for EU customers",
  description: "Three conversations hit a 30s timeout on /checkout.",
  priority: "high",
  tags: ["customer:acme"],
  links: [{ url: "https://github.com/acme/app/pull/7" }],
});

// Repeatable filters take one value or several (ORed); metadata pairs are ANDed.
for await (const open of client.issues.list({
  status: ["open", "waiting"],
  owner: "project",
  metadata: { region: "eu" },
})) {
  console.log(open.display_index, open.title, open.open_requests.length);
}

// Edit the brief. Omitted fields are unchanged, and each list replaces its
// list wholesale, so `links: []` clears the links. `ifMatch` is optional:
// with the issue's ETag, an edit against a stale read is refused with 412.
await client.issues.update(
  issue.id,
  { status: "closed", links: [] },
  { ifMatch: issueEtag(issue) },
);

// Or create one human request with a new UUIDv7 `id`; the same `id` later
// changes or resolves it.
await client.issues.update(issue.id, {
  request: {
    id: requestId,
    question: "Can we raise the gateway timeout?",
    assignee_id: memberId,
  },
});

await client.issues.delete(issue.id);

issueEtag(issue) is the ETag the Data Plane returns for an issue (its updated_at exactly as the API renders it, quoted). A brief edit and a delete take an optional { ifMatch }, sent as If-Match; a stale one is refused with 412 (an IntrospectionAPIError with status 412), and leaving it out writes without the check. A request mutation takes no ifMatch: a new request id creates the request and an existing one changes it, last write wins.

Project administration

client.apiKeys, client.applications (with .idps, .secrets and .keys), client.credentials, client.variables, client.integrations, client.judges, client.projects (with read-only .peerings), client.organizations.me(), client.oidc.me() and client.tokens.create() (a Data Plane token for one project) cover the Control Plane's project administration. They need an org credential, such as a member's Control Plane session (cpSession); a project API key carries none of their scopes. Project-scoped methods take an optional project (slug or id) for a credential that names no project.

const client = new IntrospectionClient({ cpSession: process.env.CP_SESSION });
const created = await client.apiKeys.create({ name: "ci" }, "my-project");
saveSecret(created.api_key); // returned once
await client.apiKeys.acknowledge(created.id, "my-project");
// Rotate: keep the old key working for an hour.
await client.apiKeys.delete(created.id, {
  project: "my-project",
  expires_in: 3600,
});

client.integrations.accessToken(id, { type, permissions }) mints a short-lived provider token (at most an hour, never stored) for an integration (integrations:token). GitHub is the only type today, capped at { metadata: "read" }; a request beyond the cap is refused with a 403 whose code is integration_token_cap_exceeded, never narrowed.

const { token, expires_at } = await client.integrations.accessToken(
  integration.id,
  { type: "github", permissions: { metadata: "read" } },
);

client.integrations.repositories(id) lists every repository a GitHub integration's installation can reach, in one response (integrations:read). The Control Plane reads them with its own credential, so no token reaches the caller.

const { records } = await client.integrations.repositories(integration.id);
for (const repo of records) console.log(repo.full_name, repo.default_branch);

client.projects.peerings reads a project's runtime peerings, the runtime groups it offers to other projects (projects:read). Creating or revoking a peering is a person's act in the web app, so the SDK has no write methods.

// Offered by my-project, optionally narrowed to one runtime group.
const offered = await client.projects.peerings.list(
  "my-project",
  "support-bot",
);
// Offered to my-project by other projects.
for await (const p of client.projects.peerings.granted("my-project"))
  console.log(p.project_id, p.runtime_group_id);
const one = await client.projects.peerings.get("my-project", peeringId);

Eval images and task commands

images builds or mirrors the images an eval task's compose services run as, and tasks.commands runs exec / upload / download against an eval task's live sandbox (a task created with kind: "eval" and commands: true). Both need an admin, owner or operator credential on a plane with evals enabled.

const image = await client.images.create({
  name: "hello-mcp/mcp-server",
  source: { kind: "mirror", reference: "ghcr.io/acme/mcp@sha256:…" },
});
const command = await client.tasks.commands.create(taskId, {
  kind: "exec",
  command: ["pytest", "-q"],
});
// Holds the read up to MAX_COMMAND_WAIT_SECONDS for the command to finish.
const done = await client.tasks.commands.get(taskId, command.id, {
  wait_seconds: 30,
});
console.log(done.status, done.exit_code, done.stdout);

Connections

connections lists, reads, creates and deletes the apps members connected for themselves, on the Data Plane's /v1/connections. A caller who is not an administrator always sees and manages only their own. Reads need connections:read, create needs connections:write and delete needs connections:delete. These are a different resource from the Control Plane's client.connectors.connections, which are nested under a connector.

create returns a single-use connect page; send the member to its authorize_url, and the connection exists once they finish there. On a runner, runtime defaults to the runner's runtime group. On the client, pass it.

const { authorize_url } = await runner.connections.create({ app: "gmail" });

for await (const connection of runner.connections.list({ app: "gmail" })) {
  console.log(connection.id, connection.account_name, connection.healthy);
}

// Administrators can list another member's connections.
await client.connections.list({ member_id: memberId });

await runner.connections.delete(connectionId);

Native email-code sign-in

AuthClient signs in the end users of a native Application with emailed one-time codes, shaped like the Supabase Auth client. The session belongs to a customer member of the Application's organization, is scoped to one project, and carries the Application's allowed_scopes.

import { AuthClient } from "@introspection-sdk/introspection-node";

const auth = new AuthClient({
  clientId: "intro_app_…", // a `native` Application
  project: "my-project",
  storage: sessionStore, // getItem / setItem / removeItem, sync or async
});

await auth.signInWithOtp({ email });
await auth.verifyOtp({ email, token: code });

const dp = await auth.dataPlane();
const run = await dp.tasks.start({ prompt: "Hello", runtime_id: runtimeId });
console.log(await run.text());
  • Codes are opaque. A returning user's code is 6 digits; a new user's first code is 6 characters of A–Z and 0–9. Do not restrict the input to digits. A wrong, expired or reused code throws ValidationError with code === "invalid_grant"; requesting codes too often throws RateLimitError with retryAfter.
  • Refresh. getSession() renews the session within leewaySeconds (default 60) of expiry, and every request made through auth.dataPlane() renews it once after a 401; concurrent renewals share one refresh. A refresh the server rejects signs the user out and throws AuthenticationError; a network failure keeps the session.
  • Superseded responses. A sign-in that resolves after a sign-out or a newer sign-in rejects with an AbortError instead of overwriting the newer state.
  • Events. onAuthStateChange((event, session) => …) reports INITIAL_SESSION, SIGNED_IN, TOKEN_REFRESHED and SIGNED_OUT.
  • Data Plane only. The token is a Data Plane credential, so the Control Plane routes on IntrospectionClient (runtimes, connectors, members) reject it. Pass the runtime as runtime_id when creating a task. auth.dataPlane() returns a DataPlaneClient, which implements DataPlaneResources: tasks, files, conversations, shares, events, metrics, automations, issues and connections (where create takes the runtime).
  • Sign-out clears the local session, then revokes it with POST /v1/oauth/revoke; the local session is gone even if revocation fails.

See examples/api/native-email-code.ts. Federated sign-in through your own IdP (a jwks Application) keeps its session in that IdP's SDK; exchange its token with tokenExchange() instead.

Device-code login

DeviceCodeAuth runs the login introspection login does (RFC 8628): request a code, show it to the member, and poll until they approve in the browser.

import { DeviceCodeAuth } from "@introspection-sdk/introspection-node";

const login = new DeviceCodeAuth();
const device = await login.requestCode({ project: "my-project" });
console.log(`Open ${device.verification_uri} and enter ${device.user_code}`);
const token = await login.waitForToken(device);

const renewed = await login.refresh({
  refreshToken: token.refresh_token!,
  sessionId: token.session_id!,
  orgId: token.org_id!,
});
const dev = await login.developmentCredential(renewed.access_token);
await login.revoke({
  sessionId: token.session_id!,
  orgId: token.org_id!,
  token: renewed.refresh_token ?? token.refresh_token!,
});

revoke() ends this login alone; oidcLogout({ cpSession }) signs the member out of every session, their browser's included. pollToken() makes one poll and reports pending, slow_down, denied, expired or approved. client.remotes.connectDocs() reads the protocol of the /v1/remotes/connect WebSocket that introspection dev dials with such a login.

Repositories

client.repositories looks up a project's linked repositories on the Control Plane and reads their contents through the Data Plane (repositories:read).

const [repo] = await client.repositories.list({
  project: "demo",
  slug: "acme/agent",
});

// Every entry of a directory; pages are followed automatically.
for await (const entry of client.repositories.contents(repo.id, {
  path: "src",
  ref: "main",
})) {
  console.log(entry.type, entry.path);
}

// One path: a directory page or a file, discriminated on `type`.
const readme = await client.repositories.contents.get(repo.id, "README.md");
if (readme.type === "file") console.log(readme.encoding, readme.content);

Enumerating a path that is a file throws a ValidationError; read it with contents.get() instead.

History reads the same way: commits() pages back from a branch, tag or sha (the default branch when omitted), and commit() returns one commit with its changed files and unified diff.

for await (const commit of client.repositories.commits(repo.id, {
  sha: "main",
  path: "src",
})) {
  console.log(commit.sha, commit.message);
}

const detail = await client.repositories.commit(repo.id, "abc123");
console.log(
  detail.files.map((file) => file.filename),
  detail.patch,
);

merge() merges a branch or commit sha into a branch, like GitHub's merges API (repositories:write). It resolves to the merge commit, or null when base already contains head; a conflict throws a ConflictError and changes nothing.

const merge = await client.repositories.merge(repo.id, {
  base: "main",
  head: "feature",
});
console.log(merge?.sha ?? "already up to date");

Annotations

Annotations capture what a domain expert found good or bad on an OTel span. They are labels, comments, and assignments—not numeric scores. Writes require an authenticated business-member token with annotations:write; project API keys and sandbox credentials cannot write annotations.

const client = new IntrospectionClient({
  token: memberAccessToken,
  cpSession: encodedMemberSession,
  advanced: {
    baseApiUrl: "https://api.introspection.dev", // Control Plane
    dpUrl: memberDataPlaneUrl,
  },
});

const span = {
  trace_id: "0123456789abcdef0123456789abcdef",
  span_id: "0123456789abcdef",
};

await client.tags.create({
  kind: "annotation",
  name: "strong-structure",
  color: "#f97316",
  description: "A useful structure to preserve during distillation",
});
await client.annotations.create(span, { labels: ["strong-structure"] });
await client.annotations.create(span, {
  comment: "Keep the conclusion before the evidence.",
});
await client.annotations.create(span, {
  reviewerEmails: ["[email protected]"],
});
await client.annotations.create(span, { reviewerEmails: [] });

Labels and reviewers are complete snapshots; pass [] to clear one. Comments append. Every mutation gets a UUIDv7 event_id before its first transport attempt, so an automatic retry remains one event. Supply { event_id } as the final method argument when retrying across processes. client.annotations.list() and client.tags.list("annotation") are both awaitable for the first page and async-iterable across every cursor page. Tags come in two catalogues — annotation tags (the labels above, each with a color) and access tags (the values shares and member tags carry, which have no color) — so list takes the kind, and get, update and delete take the kind and the name. Read immutable annotation history with client.events.list({ event_name: "introspection.annotation", trace_id, span_id }).

Pi instrumentation

Pi is the supported agent-instrumentation path:

pnpm add @earendil-works/pi-agent-core @earendil-works/pi-ai
import * as introspection from "@introspection-sdk/introspection-node/otel";
import { Agent } from "@earendil-works/pi-agent-core";
import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all";

await introspection.init({ serviceName: "my-app" });

const agent = new Agent({
  initialState: {
    model: getBuiltinModel("anthropic", "claude-sonnet-4-6"),
    systemPrompt: "You are a helpful support agent.",
  },
});
introspection.instrumentPi(agent, {
  conversationId: "conv_123",
  agentId: "support-agent",
  agentName: "Support",
});

await agent.prompt("Help me understand my latest invoice.");
await introspection.shutdown();

Both import styles work:

import {
  init,
  conversation,
  track,
} from "@introspection-sdk/introspection-node/otel";

Dual export

Build the OpenTelemetry provider yourself with both span processors:

import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { IntrospectionSpanProcessor } from "@introspection-sdk/introspection-node/otel";

const provider = new NodeTracerProvider({
  spanProcessors: [
    new IntrospectionSpanProcessor({ token: process.env.INTROSPECTION_TOKEN }),
    new BatchSpanProcessor(otherBackendExporter),
  ],
});
provider.register();

await introspection.init({ tracerProvider: provider });

IntrospectionSpanProcessor exports its own converted copy of each span, so the vendor processor receives the raw span and processor order is irrelevant. For a quick alternative: init({ spanProcessors: [new BatchSpanProcessor(otherBackendExporter)] }).

Pi is the only framework with a built-in integration. init() detects it and wires it automatically; to instrument an Agent by hand, import IntrospectionPiInstrumentor from @introspection-sdk/introspection-node/otel/pi. It lives on its own subpath because it reaches into @earendil-works/pi-ai at runtime, and the /otel barrel must stay importable without it.

Analytics events (track, feedback, identify)

import { IntrospectionLogs } from "@introspection-sdk/introspection-node/otel";

const logs = new IntrospectionLogs({
  token: process.env.INTROSPECTION_TOKEN,
  serviceName: "my-service",
});

await logs.withUserId("user_123", async () => {
  await logs.withConversation("conv_456", "msg_123", async () => {
    logs.feedback("thumbs_up", { comments: "Great response!" });
  });
});

logs.track("Button Clicked", { buttonId: "submit" });
logs.identify("user_123", { email: "[email protected]" });

await logs.shutdown();

Methods

| Method | Description | | --------------------------------------- | -------------------------------------------------- | | logEvent(name, attributes?, options?) | Log an app event under any custom name (see below) | | track(event, properties?) | Track any user action (an alias of logEvent) | | feedback(type, options?) | Track feedback on AI responses | | identify(userId, traits?) | Associate a user with traits | | flush() | Flush pending events | | shutdown() | Shutdown and flush |

Context helpers (OTel baggage)

| Method | Description | | ---------------------------------------------- | ---------------------------- | | withUserId(id, callback) | Set user context | | withConversation(id?, responseId?, callback) | Set conversation context | | withAgent(name, id?, callback) | Set agent context | | withAnonymousId(id, callback) | Set anonymous ID | | withBaggage(values, callback) | Set arbitrary baggage values |

Logging custom events

logEvent(name, attributes?, options?) records an app event under a name you choose — ark.feed.entry, checkout.completed — so the platform stores it and you can query it back by name. track() is a thin alias of it. Attributes are stored under properties.*.

import { IntrospectionLogs } from "@introspection-sdk/introspection-node/otel";

const logs = new IntrospectionLogs({ serviceName: "ark" }); // INTROSPECTION_TOKEN

logs.logEvent(
  "ark.feed.entry",
  { entry_id: entry.id, source: "rss", score: 0.92 },
  {
    eventId: `feed-entry:${entry.id}`, // stable id: consumers dedupe on it
    timestamp: entry.publishedAt, // Date or epoch ms; default now
    identity: { userId: entry.ownerId }, // overrides the scoped identity
    severity: "INFO", // DEBUG | INFO | WARN | ERROR
  },
);

await logs.shutdown(); // flushes

After init(), the same call is available as a module-level function:

import * as introspection from "@introspection-sdk/introspection-node/otel";

await introspection.init({ serviceName: "ark" });
introspection.logEvent("ark.feed.entry", { entry_id: "e_1" });
  • Names. Use your own namespace. Names starting with introspection. (platform events) or gen_ai. (OpenTelemetry GenAI conventions) are reserved and logEvent throws on them. track() delegates to logEvent, so it rejects them too.
  • Idempotency. Without eventId each call gets a fresh id. When delivery can repeat (retries, replayed jobs, an agent re-running a step), pass an id derived from the thing being recorded: re-sending the same eventId is how consumers recognise and drop the duplicate.
  • Identity and context. User / anonymous id and gen_ai.conversation.id / agent come from the active baggage (withUserId, withConversation, withAgent); options.identity overrides per field.

From a recipe or agent sandbox

A Runtime sandbox is started with INTROSPECTION_TOKEN and INTROSPECTION_BASE_OTEL_URL in its environment, so a recipe tool or agent step needs no configuration — construct the logger and flush before the step returns:

import { IntrospectionLogs } from "@introspection-sdk/introspection-node/otel";

const events = new IntrospectionLogs({ serviceName: "ark-recipe" });

export async function recordFeedEntries(entries: FeedEntry[]) {
  for (const entry of entries) {
    events.logEvent(
      "ark.feed.entry",
      { entry_id: entry.id, title: entry.title, url: entry.url },
      { eventId: `ark.feed.entry:${entry.id}` },
    );
  }
  await events.flush(); // the sandbox may be torn down after the step
}

Reading custom events back

Custom events are served by /v1/events under the introspection.track family, whose payload carries the original name and the properties:

import { IntrospectionClient } from "@introspection-sdk/introspection-node";

const client = new IntrospectionClient();

type TrackPayload = { name: string; properties?: Record<string, unknown> };

for await (const ev of client.events.list({
  event_name: "introspection.track",
  lookback: "24h",
})) {
  const payload = ev.payload as TrackPayload;
  if (payload.name !== "ark.feed.entry") continue;
  console.log(ev.timestamp, payload.properties?.entry_id);
}

Filtering by name server-side is not available yet — it arrives with a pending platform change. Until then, filter on payload.name client-side as above.

OpenTelemetry span processor

Attach the processor to a provider you already own, or stand one up with setupTracing. Adding a second IntrospectionSpanProcessor pointed at another OTLP endpoint dual-exports the same spans — see examples/otel/pi/dual-export.ts.

import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { IntrospectionSpanProcessor } from "@introspection-sdk/introspection-node/otel";

const provider = new NodeTracerProvider({
  spanProcessors: [
    new IntrospectionSpanProcessor({ token: process.env.INTROSPECTION_TOKEN }),
  ],
});
provider.register();

Environment variables

# Introspection API (IntrospectionClient)
export INTROSPECTION_TOKEN="intro_xxx"
export INTROSPECTION_BASE_API_URL="https://api.introspection.dev"   # optional

# Development only: route this process's tasks to your own `introspection dev`
# server when several developers share one Runtime. `introspection dev` prints
# the line to copy. No default.
export INTROSPECTION_DEV_TARGET="roland"                            # optional

# OTel (IntrospectionLogs + IntrospectionSpanProcessor)
export INTROSPECTION_BASE_OTEL_URL="https://otel.introspection.dev" # optional
export INTROSPECTION_SERVICE_NAME="my-service"                      # optional

# SDK diagnostics: error, warn, info, debug, verbose. Default: warn.
export INTROSPECTION_LOG_LEVEL="debug"                              # optional

Running behind a proxy

REST calls honour HTTPS_PROXY / HTTP_PROXY and NO_PROXY, which Node's built-in fetch ignores on its own. Nothing else needs setting, apart from a proxy that re-signs TLS with a private CA: Node only trusts that CA through NODE_EXTRA_CA_CERTS.

export HTTPS_PROXY="http://proxy.internal:3128"
export NODE_EXTRA_CA_CERTS="/etc/ssl/certs/proxy-ca.pem"   # only for a re-signing proxy

An allowlist must admit the control plane (api.introspection.dev) and your project's data plane, which is a regional host such as api.syd01.ap-southeast-2.aws.introspection.dev; introspection whoami prints both. A failed call names the host it could not reach, for example GET api.syd01.ap-southeast-2.aws.introspection.dev/v1/tasks failed: fetch failed: …. A fetch you pass in advanced.fetch, or one installed globally after the SDK loads, is used as is.