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
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export const NO_AUTO_INSTRUMENTATION = [
'suites/tracing/openai/v6/test.ts',
'suites/tracing/openai/v7/test.ts',
'suites/tracing/orchestrion-lazy-registration/test.ts',
'suites/tracing/pi-ai/test.ts',
'suites/tracing/pi-durable/test.ts',
'suites/tracing/postgres/test.ts',
'suites/tracing/postgresjs/test.ts',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
transport: loggingTransport,
integrations: [Sentry.piAiIntegration()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as Sentry from '@sentry/node';
import { complete, fauxAssistantMessage, registerFauxProvider, streamSimple } from '@earendil-works/pi-ai/compat';

// The standalone API dispatches to the providers registered with it.
const compat = registerFauxProvider({ provider: 'compat-faux', models: [{ id: 'compat-model' }] });
compat.setResponses([fauxAssistantMessage('Hello.'), fauxAssistantMessage('Goodbye.')]);

await Sentry.startSpan({ name: 'pi-ai-test' }, async () => {
const model = compat.getModel();
await complete(model, { messages: [{ role: 'user', content: 'Hi.', timestamp: Date.now() }] });
await streamSimple(model, { messages: [{ role: 'user', content: 'Bye.', timestamp: Date.now() }] }).result();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as Sentry from '@sentry/node';
import { Type } from '@earendil-works/pi-ai';
import { createModels } from '@earendil-works/pi-ai/models';
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';

// `pi-ai`'s faux provider scripts model responses in-process.
const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses([
fauxAssistantMessage(fauxToolCall('get_weather', { city: 'Berlin' }, { id: 'call_1' }), { stopReason: 'toolUse' }),
fauxAssistantMessage('It is sunny in Berlin.'),
]);

const tools = [
{
name: 'get_weather',
description: 'Get the current weather for a city.',
parameters: Type.Object({ city: Type.String() }),
},
];
const question = { role: 'user', content: 'What is the weather in Berlin?', timestamp: Date.now() };

await Sentry.startSpan({ name: 'pi-ai-test' }, async () => {
const model = faux.getModel();
const events = models.streamSimple(model, {
systemPrompt: 'You are a weather assistant.',
messages: [question],
tools,
});
for await (const _event of events) {
// Drained like an app consuming the stream.
}
const toolCall = await events.result();

await models.complete(model, {
systemPrompt: 'You are a weather assistant.',
messages: [
question,
toolCall,
{
role: 'toolResult',
toolCallId: 'call_1',
toolName: 'get_weather',
content: [{ type: 'text', text: 'sunny' }],
isError: false,
timestamp: Date.now(),
},
],
tools,
});
});
117 changes: 117 additions & 0 deletions dev-packages/node-integration-tests/suites/tracing/pi-ai/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { SerializedStreamedSpan } from '@sentry/core';
import {
GEN_AI_INPUT_MESSAGES,
GEN_AI_OPERATION_NAME,
GEN_AI_OUTPUT_MESSAGES,
GEN_AI_PROVIDER_NAME,
GEN_AI_REQUEST_MODEL,
GEN_AI_RESPONSE_FINISH_REASONS,
GEN_AI_RESPONSE_STREAMING,
GEN_AI_SYSTEM_INSTRUCTIONS,
GEN_AI_TOOL_DEFINITIONS,
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { GEN_AI_CHAT } from '@sentry/conventions/op';
import { afterAll, describe, expect } from 'vitest';
import { supports } from '../../../utils';
import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner';

/** The chat spans under `rootSpanId`, in request order, with what every pi-ai request reports. */
function chatSpans(spans: SerializedStreamedSpan[], rootSpanId: string): SerializedStreamedSpan[] {
const chats = spans
.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_CHAT)
.sort((a, b) => a.start_timestamp - b.start_timestamp);
for (const chat of chats) {
expect(chat.parent_span_id).toBe(rootSpanId);
expect(chat.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.pi_ai');
expect(chat.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat');
expect(chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0);
expect(chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBeGreaterThan(0);
}
return chats;
}

// `@earendil-works/pi-ai` declares `engines.node >= 22.19`.
describe.runIf(supports({ min: 22 }))('pi-ai integration', () => {
afterAll(() => {
cleanupChildProcesses();
});

// `@earendil-works/pi-ai` is ESM-only, so there is no CJS variant of this scenario to run.
createEsmTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('traces the requests sent through a `Models` collection', async () => {
await createRunner()
.unordered()
.expect({
span: container => {
const spans = container.items;
const root = spans.find(span => span.is_segment)!;
expect(root.name).toBe('pi-ai-test');

// One span per request: `complete` awaits a `stream` of its own, and no provider SDK
// reports the request a second time.
const chats = chatSpans(spans, root.span_id);
expect(chats.map(chat => chat.name)).toEqual(['chat faux-model', 'chat faux-model']);

const [toolCall, answer] = chats;
expect(toolCall!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('faux');
expect(toolCall!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('faux-model');
expect(toolCall!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true);
expect(toolCall!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toBe('You are a weather assistant.');
expect(toolCall!.attributes[GEN_AI_TOOL_DEFINITIONS]?.value).toContain('get_weather');
expect(toolCall!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["tool_call"]');
expect(JSON.parse(String(toolCall!.attributes[GEN_AI_INPUT_MESSAGES]?.value))).toEqual([
{ role: 'user', parts: [{ type: 'text', content: 'What is the weather in Berlin?' }] },
]);
expect(JSON.parse(String(toolCall!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value))).toEqual([
{
role: 'assistant',
parts: [{ type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Berlin"}' }],
finish_reason: 'tool_call',
},
]);

expect(answer!.attributes[GEN_AI_RESPONSE_STREAMING]).toBeUndefined();
expect(answer!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["stop"]');
expect(
JSON.parse(String(answer!.attributes[GEN_AI_INPUT_MESSAGES]?.value)).map(
(message: { role: string }) => message.role,
),
).toEqual(['user', 'assistant', 'tool']);
},
})
.start()
.completed();
});
});

createEsmTests(__dirname, 'scenario-compat.mjs', 'instrument.mjs', (createRunner, test) => {
// `compat` loads pi-ai's model catalog with `import ... with { type: 'json' }`. Deno 2.8 drops
// the import attributes once any module hook is registered and compiles the JSON as JavaScript,
// which Deno 2.9 fixed. The SDK registers one for its runtime instrumentation.
test.runIf(supports({ runtimes: ['node', 'bun'] }))(
'traces the requests sent through the standalone functions',
async () => {
await createRunner()
.unordered()
.expect({
span: container => {
const root = container.items.find(span => span.is_segment)!;
const [completed, streamed] = chatSpans(container.items, root.span_id);
expect([completed!.name, streamed!.name]).toEqual(['chat compat-model', 'chat compat-model']);

expect(completed!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('compat-faux');
expect(completed!.attributes[GEN_AI_RESPONSE_STREAMING]).toBeUndefined();
expect(streamed!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true);
expect(streamed!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).toContain('Goodbye.');
},
})
.start()
.completed();
},
);
});
});
1 change: 1 addition & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export {
createFlueInstrumentation,
instrumentPostgresJsSql,
mastraIntegration,
piAiIntegration,
piDurableIntegration,
mcpServerIntegration,
SentryMastraExporter,
Expand Down
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export {
langChainIntegration,
langGraphIntegration,
mastraIntegration,
piAiIntegration,
piDurableIntegration,
mcpServerIntegration,
SentryMastraExporter,
Expand Down
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export {
langChainIntegration,
langGraphIntegration,
mastraIntegration,
piAiIntegration,
piDurableIntegration,
mcpServerIntegration,
SentryMastraExporter,
Expand Down
1 change: 1 addition & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export {
neonIntegration,
nitroIntegration,
openAIIntegration,
piAiIntegration,
piDurableIntegration,
postgresIntegration,
postgresJsIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/deno/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export {
langChainIntegration,
langGraphIntegration,
mastraIntegration,
piAiIntegration,
piDurableIntegration,
mcpServerIntegration,
SentryMastraExporter,
Expand Down
1 change: 1 addition & 0 deletions packages/elysia/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export {
langGraphIntegration,
createFlueInstrumentation,
mastraIntegration,
piAiIntegration,
piDurableIntegration,
mcpServerIntegration,
SentryMastraExporter,
Expand Down
1 change: 1 addition & 0 deletions packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export {
langChainIntegration,
langGraphIntegration,
mastraIntegration,
piAiIntegration,
piDurableIntegration,
mcpServerIntegration,
SentryMastraExporter,
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export {
lruMemoizerIntegration,
createFlueInstrumentation,
mastraIntegration,
piAiIntegration,
piDurableIntegration,
mcpServerIntegration,
SentryMastraExporter,
Expand Down
3 changes: 3 additions & 0 deletions packages/server-utils/src/ai/pi-ai/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const PI_AI_INTEGRATION_NAME = 'PiAi' as const;

export const PI_AI_ORIGIN = 'auto.ai.pi_ai';
1 change: 1 addition & 0 deletions packages/server-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export { langChainIntegration } from './integrations/langchain';
export { langGraphIntegration } from './integrations/langgraph';
export { createFlueInstrumentation } from './ai/flue';
export { flueIntegration } from './integrations/flue';
export { piAiIntegration } from './integrations/pi-ai';
export { piDurableIntegration } from './integrations/pi-durable';
export type { FlueOptions } from './ai/flue';
export { mastraIntegration } from './integrations/mastra';
Expand Down
112 changes: 112 additions & 0 deletions packages/server-utils/src/integrations/pi-ai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as diagnosticsChannel from '../utils/diagnosticsChannel';
import type { IntegrationFn, Span } from '@sentry/core';
import { defineIntegration, isThenable, startInactiveSpan } from '@sentry/core';
import type { GenAiOptions } from '../ai/core/utils';
import { resolveAIRecordingOptions } from '../ai/core/utils';
import { getPiAiChatSpanOptions, setPiAiResponseAttributes } from '../ai/pi-ai/chat';
import { PI_AI_INTEGRATION_NAME, PI_AI_ORIGIN } from '../ai/pi-ai/constants';
import type { PiAiContext } from '../ai/pi-ai/messages';
import { skipPiAiProviderIntegrations } from '../ai/pi-ai/providers';
import type { PiEventStream, PiStreamOptions } from '../ai/pi-ai/types';
import type { OrchestrionChannelContext } from '../orchestrion/types';
import { CHANNELS } from '../orchestrion/channels';
import { piAiModuleNames } from '../orchestrion/config/pi-ai';
import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation';
import { bindTracingChannelToSpan } from '../tracing-channel';

export type PiAiOptions = GenAiOptions;

const _piAiIntegration = ((options: PiAiOptions = {}) => {
return {
name: PI_AI_INTEGRATION_NAME,
setup(client) {
invokeOrchestrionInstrumentation(client, piAiModuleNames, instrumentPiAi, [options]);
},
};
}) satisfies IntegrationFn;

function instrumentPiAi(options: PiAiOptions): void {
const streamChannel = diagnosticsChannel.tracingChannel<OrchestrionChannelContext>(CHANNELS.PI_AI_STREAM);
const completeChannel = diagnosticsChannel.tracingChannel<OrchestrionChannelContext>(CHANNELS.PI_AI_COMPLETE);

// `complete` awaits `stream`, and the `compat` functions hand some models to a `Models` of their
// own, so a traced call can make another one synchronously. Only the outermost gets a span.
let depth = 0;
const startSpan = (data: OrchestrionChannelContext, streaming: boolean): Span | undefined => {
if (depth > 0) {
return undefined;
}
skipPiAiProviderIntegrations();
const [model, context, callOptions] = data.arguments ?? [];
return startInactiveSpan(
getPiAiChatSpanOptions({
model: model ?? {},
// The deferred calls pass a handle here, which carries no request content.
context: context as PiAiContext | undefined,
callOptions: callOptions as PiStreamOptions | undefined,
streaming,
origin: PI_AI_ORIGIN,
recordInputs: resolveAIRecordingOptions(options).recordInputs,
}),
);
};

const setResponse = (span: Span, message: unknown): void => {
if (message && typeof message === 'object') {
setPiAiResponseAttributes(span, message, resolveAIRecordingOptions(options).recordOutputs);
}
};

bindTracingChannelToSpan(streamChannel, data => startSpan(data, true), {
// The stream is returned before the request settles; its `result()` settles with the message.
deferSpanEnd: ({ span, data, end }) => {
const events = data.result as Partial<PiEventStream> | undefined;
const settled = 'error' in data || typeof events?.result !== 'function' ? undefined : events.result();
if (!isThenable(settled)) {
return false;
}
settled.then(message => {
setResponse(span, message);
end();
}, end);
return true;
},
});

bindTracingChannelToSpan(completeChannel, data => startSpan(data, false), {
beforeSpanEnd: (span, data) => {
if (!('error' in data)) {
setResponse(span, data.result);
}
},
});

for (const channel of [streamChannel, completeChannel]) {
channel.start.subscribe(() => {
depth++;
});
channel.end.subscribe(() => {
depth--;
});
}
}

/**
* Traces the requests sent with pi-ai (`@earendil-works/pi-ai` >= 0.80.0 < 2) as `gen_ai.chat` spans,
* through `createModels()` or the standalone `stream` and `complete` functions. Needs the Sentry
* runtime hook (`--import`) or bundler plugin.
*
* Not enabled by default yet. Flue and pi-durable send their model calls through pi-ai and still
* report them as chat spans of their own, so enabling this alongside them reports each call twice.
*
* Once pi-ai has sent a request, the `openai`, `@anthropic-ai/sdk`, `@google/genai` and Workers AI
* integrations stop recording requests for the rest of the process, since pi-ai sends through them.
*
* @example
* ```javascript
* Sentry.init({
* integrations: [Sentry.piAiIntegration()],
* });
* ```
*/
export const piAiIntegration = defineIntegration(_piAiIntegration);
Loading
Loading