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
4,579 changes: 2,033 additions & 2,546 deletions package-lock.json

Large diffs are not rendered by default.

50 changes: 41 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "netra-sdk",
"version": "1.3.0",
"version": "1.5.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 @@ -49,15 +49,15 @@
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.0.1",
"@opentelemetry/instrumentation-undici": "^0.6.0",
"@opentelemetry/core": "^2.8.0",
"@opentelemetry/instrumentation-undici": "^0.10.0",
"@opentelemetry/instrumentation-express": "^0.67.0",
"@opentelemetry/instrumentation-http": "^0.219.0",
"@opentelemetry/otlp-transformer": "^0.211.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-trace-base": "^2.0.1",
"@traceloop/node-server-sdk": "^0.22.5",
"axios": "^1.13.4",
"@opentelemetry/otlp-transformer": "^0.220.0",
"@opentelemetry/resources": "^2.8.0",
"@opentelemetry/sdk-trace-base": "^2.8.0",
"@traceloop/node-server-sdk": "^0.27.0",
"axios": "^1.18.1",
"dotenv": "^17.2.3",
"p-limit": "^7.2.0",
"shimmer": "^1.2.1"
Expand All @@ -67,10 +67,11 @@
"@langchain/ollama": "^1.2.1",
"@mistralai/mistralai": ">=1.0.0",
"@google/generative-ai": "^0.24.1",
"@google/genai": ">=1.0.0",
"@opentelemetry/instrumentation": ">=0.53.0",
"@opentelemetry/instrumentation-express": "^0.67.0",
"@opentelemetry/instrumentation-http": "^0.219.0",
"@opentelemetry/instrumentation-undici": "^0.6.0",
"@opentelemetry/instrumentation-undici": "^0.10.0",
"@prisma/instrumentation": "^5.0.0",
"groq-sdk": ">=0.3.0",
"openai": "^4.0.0 || ^5.0.0 || ^6.0.0",
Expand Down Expand Up @@ -105,6 +106,9 @@
"@google/generative-ai": {
"optional": true
},
"@google/genai": {
"optional": true
},
"@anthropic-ai/sdk": {
"optional": true
},
Expand All @@ -118,5 +122,33 @@
"ts-node": "^10.9.2",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
},
"overrides": {
"@opentelemetry/core": "^2.8.0",
"@opentelemetry/resources": "^2.8.0",
"@opentelemetry/sdk-trace-base": "^2.8.0",
"@opentelemetry/sdk-trace-node": "^2.8.0",
"@opentelemetry/sdk-metrics": "^2.8.0",
"@opentelemetry/sdk-logs": "^0.220.0",
"@opentelemetry/sdk-node": "^0.220.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.220.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.220.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.220.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.220.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.220.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.220.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.220.0",
"@opentelemetry/exporter-prometheus": "^0.220.0",
"@opentelemetry/exporter-zipkin": "^2.8.0",
"@opentelemetry/otlp-exporter-base": "^0.220.0",
"@opentelemetry/otlp-grpc-exporter-base": "^0.220.0",
"@opentelemetry/otlp-transformer": "^0.220.0",
"@opentelemetry/propagator-b3": "^2.8.0",
"@opentelemetry/propagator-jaeger": "^2.8.0",
"@opentelemetry/instrumentation-undici": "^0.10.0",
"uuid": "^11.1.1",
"esbuild": "^0.28.1"
}
}
46 changes: 44 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { Logger } from "./logger";
import { SDK_VERSION } from "./version";

export interface NetraConfig {
appName?: string;
Expand Down Expand Up @@ -30,7 +31,8 @@ export enum NetraInstruments {

// LLM Providers
OPENAI = "openai",
GOOGLE_GENERATIVE_AI = "google_genai",
GOOGLE_GENAI = "google_genai",
GOOGLE_GENERATIVE_AI = "google_generative_ai",
MISTRAL = "mistral_ai",
GROQ = "groq",
VERTEX_AI = "vertexai",
Expand Down Expand Up @@ -69,6 +71,7 @@ export const DEFAULT_INSTRUMENTS_FOR_ROOT: Set<NetraInstruments> = new Set([
NetraInstruments.ANTHROPIC,
NetraInstruments.GROQ,
NetraInstruments.MISTRAL,
NetraInstruments.GOOGLE_GENAI,
NetraInstruments.GOOGLE_GENERATIVE_AI,
NetraInstruments.VERTEX_AI,
NetraInstruments.TOGETHER,
Expand Down Expand Up @@ -101,14 +104,17 @@ export const DEFAULT_INSTRUMENTS: Set<NetraInstruments> = new Set([
export class Config {
static readonly SDK_NAME = "netra";
static readonly LIBRARY_NAME = "netra";
static readonly LIBRARY_VERSION = "1.0.0";
static readonly LIBRARY_VERSION = SDK_VERSION;
static readonly TRIAL_BLOCK_DURATION_SECONDS = 900; // 15 minutes
static readonly ATTRIBUTE_MAX_LEN = parseInt(
process.env.NETRA_ATTRIBUTE_MAX_LEN || "50000",
);
static readonly CONVERSATION_MAX_LEN = parseInt(
process.env.NETRA_CONVERSATION_CONTENT_MAX_LEN || "50000",
);
static readonly SPAN_ATTRIBUTE_MAX_SIZE = parseInt(
process.env.NETRA_SPAN_ATTRIBUTE_MAX_SIZE || "30000",
);

appName: string;
otlpEndpoint?: string;
Expand Down Expand Up @@ -324,5 +330,41 @@ export class Config {
.join(",");
process.env.TRACELOOP_HEADERS = headerStr;
}

// Set OTEL_RESOURCE_ATTRIBUTES so the TracerProvider Resource carries
// service.name and deployment.environment attributes.
this._setResourceAttributesEnv();
}

private _setResourceAttributesEnv(): void {
// Start with Netra defaults (lowest priority)
const attrs: Record<string, string> = {
"deployment.environment": this.environment,
"service.name": this.appName,
};

// Config-level resourceAttributes override defaults
for (const [k, v] of Object.entries(this.resourceAttributes)) {
attrs[k] = String(v);
}

// Pre-existing OTEL_RESOURCE_ATTRIBUTES win (highest priority)
const existing = process.env.OTEL_RESOURCE_ATTRIBUTES;
if (existing) {
for (const pair of existing.split(",")) {
const eqIdx = pair.indexOf("=");
if (eqIdx <= 0) continue;
const key = decodeURIComponent(pair.slice(0, eqIdx).trim());
if (key) {
attrs[key] = decodeURIComponent(pair.slice(eqIdx + 1).trim());
}
}
}

const encodeAttrValue = (s: string) => encodeURIComponent(s);

process.env.OTEL_RESOURCE_ATTRIBUTES = Object.entries(attrs)
.map(([k, v]) => `${encodeAttrValue(k)}=${encodeAttrValue(v)}`)
.join(",");
}
}
150 changes: 50 additions & 100 deletions src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
* Decorators for easy instrumentation
*/

import { trace, Span, SpanStatusCode } from "@opentelemetry/api";
import { context, trace, Span, SpanStatusCode } from "@opentelemetry/api";
import { Config } from "./config";
import { Logger } from "./logger";
import { SessionManager } from "./session-manager";
import { SpanType, DecoratorOptions } from "./types";
import { wrapResponse } from "./utils/response-handler";
import { safeStringify, serializeValue } from "./utils/serialization";

type AnyFunction = (...args: any[]) => any;
type AsyncFunction = (...args: any[]) => Promise<any>;
Expand All @@ -19,40 +21,6 @@ type MethodDecoratorFn = (
) => void;
type UnifiedDecorator = ClassDecoratorFn & MethodDecoratorFn;

/**
* Circular-reference-safe JSON.stringify. Non-serializable values
* (functions, symbols, cycles, complex class instances) become
* descriptive placeholders instead of throwing.
*/
function safeStringify(value: any, maxLen = 1000): string {
const seen = new WeakSet<object>();
try {
return JSON.stringify(value, (_key, val) => {
if (typeof val === "function") return `[Function: ${val.name || "anonymous"}]`;
if (typeof val === "symbol") return val.toString();
if (typeof val === "bigint") return val.toString();
if (val !== null && typeof val === "object") {
if (seen.has(val)) return "[Circular]";
seen.add(val);
const name = val.constructor?.name;
if (name && name !== "Object" && name !== "Array" && Object.keys(val).length > 20) {
return `[${name}]`;
}
}
return val;
}).substring(0, maxLen);
} catch {
return value?.constructor?.name ? `[${value.constructor.name}]` : String(typeof value);
}
}

function serializeValue(value: any): string {
if (value === null || value === undefined) return String(value);
const t = typeof value;
if (t === "string" || t === "number" || t === "boolean") return String(value);
return safeStringify(value);
}

/**
* Returns true if the span already has a non-empty `output` attribute.
* Checks both the public `attributes` property and the internal `_attributes`
Expand All @@ -70,22 +38,18 @@ function spanHasOutput(span: Span): boolean {
return false;
}

function addInputAttributes(
span: Span,
args: any[],
entityType: string,
): void {
function addInputAttributes(span: Span, args: any[], entityType: string): void {
span.setAttribute(`${Config.LIBRARY_NAME}.entity.type`, entityType);
if (args.length > 0) {
span.setAttribute("input", safeStringify(args));
span.setAttribute("input", safeStringify(args, Config.ATTRIBUTE_MAX_LEN));
}
}

function addOutputAttributes(span: Span, result: any): void {
// Skip if the user already set output explicitly inside the decorated function
if (spanHasOutput(span)) return;
try {
span.setAttribute("output", serializeValue(result));
span.setAttribute("output", serializeValue(result, Config.ATTRIBUTE_MAX_LEN));
} catch (e) {
span.setAttribute("output_error", String(e));
}
Expand Down Expand Up @@ -119,14 +83,13 @@ function createFunctionWrapper<T extends AnyFunction>(
SessionManager.registerSpan(spanName, span);
};

const handleError = (span: Span, e: any) => {
const recordError = (span: Span, e: any) => {
span.setAttribute(`${Config.LIBRARY_NAME}.entity.error`, String(e));
span.setStatus({
code: SpanStatusCode.ERROR,
message: e instanceof Error ? e.message : String(e),
});
span.recordException(e);
throw e;
};

const cleanup = (span: Span) => {
Expand All @@ -135,65 +98,52 @@ function createFunctionWrapper<T extends AnyFunction>(
SessionManager.popEntity(entityType);
};

if (isAsync) {
const wrapper = async function (this: any, ...args: any[]) {
SessionManager.pushEntity(entityType, spanName);
const tracer = trace.getTracer(moduleName);
return tracer.startActiveSpan(spanName, async (span) => {
try {
initSpan(span);
addInputAttributes(span, args, entityType);
const result = await (func as AsyncFunction).call(this, ...args);
addOutputAttributes(span, result);
return result;
} catch (e: any) {
handleError(span, e);
} finally {
cleanup(span);
}
});
};
return wrapper as T;
} else {
const wrapper = function (this: any, ...args: any[]) {
SessionManager.pushEntity(entityType, spanName);
const tracer = trace.getTracer(moduleName);
return tracer.startActiveSpan(spanName, (span) => {
// Track whether the function returned a promise so the finally block
// can skip its cleanup — the promise chain owns span lifecycle in that case.
let returnedPromise = false;
try {
initSpan(span);
addInputAttributes(span, args, entityType);
const result = (func as AnyFunction).call(this, ...args);

// Detect promise-returning non-async functions (e.g. `function foo() { return fetch(...) }`)
// Without this check, addOutputAttributes would capture the raw Promise object and
// cleanup() would end the span before the async work completes.
if (result != null && typeof (result as any).then === "function") {
returnedPromise = true;
return (result as Promise<any>)
.then((resolved: any) => {
addOutputAttributes(span, resolved);
return resolved;
})
.catch((e: any) => handleError(span, e))
.finally(() => cleanup(span));
const wrapperFn = isAsync
? async function (this: any, ...args: any[]) {
SessionManager.pushEntity(entityType, spanName);
const tracer = trace.getTracer(moduleName);
return tracer.startActiveSpan(spanName, async (span) => {
try {
initSpan(span);
addInputAttributes(span, args, entityType);
const result = await (func as AsyncFunction).call(this, ...args);
const spanCtx = trace.setSpan(context.active(), span);
return wrapResponse(result, {
withContext: (fn) => context.with(spanCtx, fn),
onError: (e) => recordError(span, e),
onSuccess: (value) => addOutputAttributes(span, value),
finalize: () => cleanup(span),
});
} catch (e: any) {
recordError(span, e);
cleanup(span);
throw e;
}

addOutputAttributes(span, result);
return result;
} catch (e: any) {
handleError(span, e);
} finally {
if (!returnedPromise) {
});
}
: function (this: any, ...args: any[]) {
SessionManager.pushEntity(entityType, spanName);
const tracer = trace.getTracer(moduleName);
return tracer.startActiveSpan(spanName, (span) => {
try {
initSpan(span);
addInputAttributes(span, args, entityType);
const result = (func as AnyFunction).call(this, ...args);
const spanCtx = trace.setSpan(context.active(), span);
return wrapResponse(result, {
withContext: (fn) => context.with(spanCtx, fn),
onError: (e) => recordError(span, e),
onSuccess: (value) => addOutputAttributes(span, value),
finalize: () => cleanup(span),
});
} catch (e: any) {
recordError(span, e);
cleanup(span);
throw e;
}
}
});
};
return wrapper as T;
}
});
};
return wrapperFn as T;
}

const SKIP_STATIC_PROPS = new Set([
Expand Down
Loading