Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.8.0] - 2026-08-03

### Added

- **Postscript Failed Status**: After-hook failures (`after`, `afterEach`, `afterAll`) on otherwise successful scenarios are now reported as `postscript_failed` instead of being logged and ignored. Existing failure statuses (`failed` / `prescript_failed`) are never overwritten. `after` and `afterEach` always both attempt to run; `afterAll` failure marks only successfully completed scenarios.

## [1.7.0] - 2026-07-27
## [1.9.0] - Unreleased

### Added

- **Simulation Lifecycle Hooks**: Added prescript/postscript support for multi-turn simulations via `SimulationHooks` (`beforeAll`, `beforeEach`, `before`, `after`, `afterEach`, `afterAll`). Hooks can return setup context passed into `BaseTask.run`, and the run uses a two-phase initialize / first-turn flow so hooks execute before any LLM spend. Execution order is `beforeAll` → `beforeEach` → item-specific `before` → task → item-specific `after` → `afterEach` → `afterAll`. `beforeAll` failure aborts the run as `prescript_failed`; item `before` failure marks only that scenario; `after`, `afterEach`, and `afterAll` failures are logged and do not affect status.

## [Unreleased]

### Added
- **Time to First Token (TTFT) & Relative TTFT**: All LLM generation spans now record `gen_ai.performance.time_to_first_token` (seconds from call start to first content chunk), `gen_ai.performance.relative_time_to_first_token` (seconds from trace root span start to first content chunk), and `gen_ai.performance.time_to_first_token.timestamp` (absolute ISO 8601 UTC time of first token). Supported across OpenAI, Anthropic, Groq, Mistral, Google GenAI, and Google Generative AI for both streaming and non-streaming calls. RTTFT is silently skipped when no root span exists.

- **Opt-in prompt caching** — `Netra.prompts.getPrompt()` accepts `useCache` and `cacheTtl`. When `useCache` is true, responses are served from an in-memory TTL cache (default TTL: `PROMPT_CACHE_TTL_SECONDS` = 60). Caching is off by default.
- **Models API** — `Netra.models.getModelPricing()` fetches model pricing (optional `name` filter) with the same opt-in cache pattern (`useCache`, `cacheTtl`; default TTL: `MODEL_PRICING_CACHE_TTL_SECONDS` = 300).
Expand All @@ -30,6 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Prompt cache TTL** — Default TTL is the module constant `PROMPT_CACHE_TTL_SECONDS` (60). Override per call with `cacheTtl`. Removed unused `cacheTtlSeconds` init config and `NETRA_CACHE_TTL_SECONDS` env var.

## [1.8.0] - 2026-08-03

### Added

- **Postscript Failed Status**: After-hook failures (`after`, `afterEach`, `afterAll`) on otherwise successful scenarios are now reported as `postscript_failed` instead of being logged and ignored. Existing failure statuses (`failed` / `prescript_failed`) are never overwritten. `after` and `afterEach` always both attempt to run; `afterAll` failure marks only successfully completed scenarios.

## [1.7.0] - 2026-07-27

### Added

- **Simulation Lifecycle Hooks**: Added prescript/postscript support for multi-turn simulations via `SimulationHooks` (`beforeAll`, `beforeEach`, `before`, `after`, `afterEach`, `afterAll`). Hooks can return setup context passed into `BaseTask.run`, and the run uses a two-phase initialize / first-turn flow so hooks execute before any LLM spend. Execution order is `beforeAll` → `beforeEach` → item-specific `before` → task → item-specific `after` → `afterEach` → `afterAll`. `beforeAll` failure aborts the run as `prescript_failed`; item `before` failure marks only that scenario; `after`, `afterEach`, and `afterAll` failures are logged and do not affect status.

## [1.6.0] - 2026-07-17

### Added
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 19 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "netra-sdk",
"version": "1.8.0",
"version": "1.9.0-dev.0",
"description": "A comprehensive TypeScript/JavaScript SDK for AI application observability built on top of OpenTelemetry and Traceloop",
"type": "module",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -79,40 +79,46 @@
"openai": "^4.0.0 || ^5.0.0 || ^6.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/instrumentation": {
"@anthropic-ai/sdk": {
"optional": true
},
"@opentelemetry/instrumentation-http": {
"@google/genai": {
"optional": true
},
"@opentelemetry/instrumentation-express": {
"@google/generative-ai": {
"optional": true
},
"@opentelemetry/instrumentation-undici": {
"@langchain/langgraph": {
"optional": true
},
"@prisma/instrumentation": {
"@langchain/ollama": {
"optional": true
},
"openai": {
"@mistralai/mistralai": {
"optional": true
},
"groq-sdk": {
"@openai/agents": {
"optional": true
},
"@mistralai/mistralai": {
"@opentelemetry/instrumentation": {
"optional": true
},
"@google/generative-ai": {
"@opentelemetry/instrumentation-express": {
"optional": true
},
"@google/genai": {
"@opentelemetry/instrumentation-http": {
"optional": true
},
"@anthropic-ai/sdk": {
"@opentelemetry/instrumentation-undici": {
"optional": true
},
"@openai/agents": {
"@prisma/instrumentation": {
"optional": true
},
"groq-sdk": {
"optional": true
},
"openai": {
"optional": true
}
},
Expand Down
18 changes: 1 addition & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,23 +235,6 @@ export class Netra {
}

this._initialized = true;
Logger.info("Netra successfully initialized.");

{
let pkgVersion = Config.LIBRARY_VERSION;
let pkgPath = "unknown";
try {
const req = createRequire(import.meta.url);
pkgPath = req.resolve("../package.json");
const pkg = req("../package.json");
pkgVersion = pkg?.version || pkgVersion;
} catch {
// keep defaults
}
Logger.debug(
`SDK version=${pkgVersion} libraryVersion=${Config.LIBRARY_VERSION} build=langgraph-parenting-v3 packageJson=${pkgPath}`,
);
}

// Graceful shutdown logic
const handleSignal = async (signal: string) => {
Expand Down Expand Up @@ -287,6 +270,7 @@ export class Netra {

// Wait for all async instrumentations to be ready
await instrumentationsReady;
Logger.info("Netra successfully initialized.");
}

static async shutdown(): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions src/instrumentation/anthropic/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Span, SpanStatusCode } from "@opentelemetry/api";
import { Logger } from "../../logger";
import type { FirstTokenTracker } from "../../utils/span-timing";
import {
setRequestAttributes as setBaseRequestAttributes,
setResponseAttributes as setBaseResponseAttributes,
Expand All @@ -13,6 +14,7 @@ export function processStreamChunk(
completeResponse: Record<string, any>,
chunk: any,
span: Span,
tokenTracker?: FirstTokenTracker,
): void {
try {
switch (chunk.type) {
Expand Down Expand Up @@ -67,6 +69,7 @@ export function processStreamChunk(
targetBlock.input += chunk.delta.partial_json ?? "";
} else if (chunk.delta?.text) {
targetBlock.text += chunk.delta.text;
tokenTracker?.markFirstToken();
}
break;
}
Expand Down
88 changes: 67 additions & 21 deletions src/instrumentation/anthropic/wrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
import { Logger } from "../../logger";
import { wrapResponse } from "../../utils/response-handler";
import { safeStringify } from "../../utils/serialization";
import {
FirstTokenTracker,
recordNonStreamingTimingAttributes,
} from "../../utils/span-timing";
import { SpanAttributes } from "../span-attributes";
import {
defineHidden,
Expand Down Expand Up @@ -38,6 +42,8 @@ const WRAPPER_OWN_PROPS = new Set([
"spanFinalized",
"completionPending",
"listenerMap",
"tokenTracker",
"ttftListener",
]);

const EVENT_EMITTER_METHODS = new Set([
Expand Down Expand Up @@ -152,6 +158,8 @@ class MessageStreamWrapper {
private spanFinalized = false;
private completionPending = false;
private listenerMap = new WeakMap<Function, Map<string, Function[]>>();
private tokenTracker!: FirstTokenTracker;
private ttftListener!: (data: any) => void;

constructor(
span: Span,
Expand All @@ -165,6 +173,7 @@ class MessageStreamWrapper {
defineHidden(this, "messageStream", messageStream);
defineHidden(this, "startTime", startTime);
defineHidden(this, "requestKwargs", requestKwargs);
defineHidden(this, "tokenTracker", new FirstTokenTracker(span, startTime));
defineHidden(
this,
"spanContext",
Expand Down Expand Up @@ -238,7 +247,13 @@ class MessageStreamWrapper {
if (prop === "removeAllListeners") {
return function (event?: string) {
target.listenerMap = new WeakMap();
return method.call(target.messageStream, event);
const result = method.call(target.messageStream, event);
if (!event) {
target.attachSafetyNetListeners();
} else if (event === "text") {
target.messageStream.on("text", target.ttftListener);
}
return result;
};
}
return method.bind(target.messageStream);
Expand All @@ -254,12 +269,20 @@ class MessageStreamWrapper {
const result = await method.call(target.messageStream, ...args);

if (prop === "finalMessage" || prop === "done") {
if (result) {
const hasText = Array.isArray(result.content) &&
result.content.some((b: any) => b.type === "text" && b.text);
if (hasText) {
target.tokenTracker.markFirstToken();
}
}
target.finalizeSpanFromMessage(result);
} else if (prop === "finalText") {
if (typeof result === "string" && result.length > 0) {
target.completeResponse.content = [
{ type: "text", text: result },
];
target.tokenTracker.markFirstToken();
} else {
target.flushCurrentText();
}
Expand All @@ -282,27 +305,35 @@ class MessageStreamWrapper {
});
}

private attachSafetyNetListeners(): void {
this.ttftListener = (data: any) => {
if (data) this.tokenTracker.markFirstToken();
};
this.messageStream.on("text", this.ttftListener);

this.messageStream.on("end", () => {
if (!this.completionPending) {
this.finalizeSpanOnce(SpanStatusCode.OK);
}
});
this.messageStream.on("error", (err: any) => {
if (err && !this.spanFinalized) {
this.span.setStatus({
code: SpanStatusCode.ERROR,
message: err instanceof Error ? err.message : String(err),
});
this.span.recordException(
err instanceof Error ? err : new Error(String(err)),
);
}
this.finalizeSpanOnce(SpanStatusCode.ERROR);
});
}

private registerSafetyNetListeners(): void {
try {
if (typeof this.messageStream?.on !== "function") return;

this.messageStream.on("end", () => {
if (!this.completionPending) {
this.finalizeSpanOnce(SpanStatusCode.OK);
}
});
this.messageStream.on("error", (err: any) => {
if (err && !this.spanFinalized) {
this.span.setStatus({
code: SpanStatusCode.ERROR,
message: err instanceof Error ? err.message : String(err),
});
this.span.recordException(
err instanceof Error ? err : new Error(String(err)),
);
}
this.finalizeSpanOnce(SpanStatusCode.ERROR);
});
this.attachSafetyNetListeners();
} catch (e) {
Logger.error(
"netra.instrumentation.anthropic: safety net listener registration failed",
Expand All @@ -319,7 +350,12 @@ class MessageStreamWrapper {
let errorOccurred = false;
try {
for await (const chunk of this.messageStream) {
processStreamChunk(this.completeResponse, chunk, this.span);
processStreamChunk(
this.completeResponse,
chunk,
this.span,
this.tokenTracker,
);
yield chunk;
}
} catch (err) {
Expand Down Expand Up @@ -350,6 +386,7 @@ class MessageStreamWrapper {
this.completeResponse.currentText = "";
}
this.completeResponse.currentText += data;
if (data) this.tokenTracker.markFirstToken();
break;

case "contentBlock":
Expand Down Expand Up @@ -444,13 +481,19 @@ function anthropicWrapper(
model: "",
usage: {},
};
const tokenTracker = new FirstTokenTracker(span, startTime);

return wrapResponse(
response,
{
withContext: (fn) => context.with(spanContext, fn),
onChunk: (chunk) =>
processStreamChunk(completeResponse, chunk, span),
processStreamChunk(
completeResponse,
chunk,
span,
tokenTracker,
),
onError: (error) => {
Logger.error("netra.instrumentation.anthropic:", error);
span.setStatus({
Expand All @@ -468,6 +511,9 @@ function anthropicWrapper(
"llm.response.duration",
(endTime - startTime) / 1000,
);
if (requestType !== "batches") {
recordNonStreamingTimingAttributes(span, startTime, endTime);
}
},
finalize: (status) => {
const hasStreamData =
Expand Down
7 changes: 3 additions & 4 deletions src/instrumentation/google-genai/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { Span } from "@opentelemetry/api";
import { Logger } from "../../logger";
import { safeStringify } from "../../utils/serialization";
import type { FirstTokenTracker } from "../../utils/span-timing";
import { SpanAttributes } from "../span-attributes";
import {
TracedMessage,
Expand Down Expand Up @@ -488,6 +489,7 @@ export function processStreamChunk(
chunk: any,
span: Span,
startTime: number,
tokenTracker?: FirstTokenTracker,
): void {
try {
if (chunk.modelVersion) {
Expand All @@ -502,10 +504,7 @@ export function processStreamChunk(
if (chunkText && chunkText.length > 0) {
if (!accumulated._text) {
accumulated._text = chunkText;
span.setAttribute(
"gen_ai.performance.time_to_first_token",
(Date.now() - startTime) / 1000,
);
tokenTracker?.markFirstToken();
} else {
accumulated._text += chunkText;
}
Expand Down
Loading