diff --git a/plugins/index.ts b/plugins/index.ts index 641738b50..a35bc99a4 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -66,6 +66,7 @@ import { handler as javelinguardrails } from './javelin/guardrails'; import { handler as f5GuardrailsScan } from './f5-guardrails/scan'; import { handler as azureShieldPrompt } from './azure/shieldPrompt'; import { handler as azureProtectedMaterial } from './azure/protectedMaterial'; +import { handler as levoObservability } from './levo-ai/index'; export const plugins = { default: { @@ -176,4 +177,7 @@ export const plugins = { 'f5-guardrails': { scan: f5GuardrailsScan, }, + levo: { + observability: levoObservability, + }, }; diff --git a/plugins/levo-ai/README.md b/plugins/levo-ai/README.md new file mode 100644 index 000000000..72857ba6d --- /dev/null +++ b/plugins/levo-ai/README.md @@ -0,0 +1,194 @@ +# Levo AI Plugin + +This plugin integrates Portkey Gateway with Levo AI's API security and observability platform, enabling comprehensive monitoring, analysis, and security testing of LLM API traffic. + +## Overview + +Levo AI is an API security and observability platform that helps organizations secure and monitor their APIs in production. The Portkey Gateway plugin sends request and response traces to your Levo AI Collector in OpenTelemetry (OTLP) format, enabling deep visibility into your LLM API traffic. + +## Features + +- **Complete API Visibility**: Capture full request and response payloads for all LLM API calls +- **Security Analysis**: Detect sensitive data (PII, secrets) and security issues in API traffic +- **Performance Monitoring**: Track latency, error rates, and throughput across all LLM providers +- **Cost Attribution**: Monitor token usage and costs per user, team, or application +- **Distributed Tracing**: Correlate traces across your infrastructure with OpenTelemetry standards +- **Multi-Tenant Support**: Route traces by organization and workspace for multi-tenant deployments + +## Setup + +### Prerequisites + +1. **Levo AI Collector**: Deploy the Levo AI Collector in your environment ([deployment guide](https://docs.levo.ai/)) +2. **Organization ID**: Obtain your Levo organization ID from your Levo AI account settings +3. **Network Access**: Ensure Portkey Gateway can reach your Levo AI Collector endpoint + +### Configuration + +The plugin is configured via the `x-portkey-config` header or Portkey SDK configuration. + +## Usage + +### Using with x-portkey-config Header + +```json +{ + "provider": "openai", + "api_key": "your-openai-key", + "after_request_hooks": [ + { + "id": "levo.observability", + "organizationId": "your-levo-org-id", + "endpoint": "http://levo-collector:4318/v1/traces" + } + ] +} +``` + +### Using with Portkey SDK (Node.js) + +```javascript +import Portkey from 'portkey-ai'; + +const portkey = new Portkey({ + apiKey: process.env.PORTKEY_API_KEY, + config: { + provider: 'openai', + api_key: process.env.OPENAI_API_KEY, + after_request_hooks: [ + { + id: 'levo.observability', + organizationId: process.env.LEVO_ORG_ID, + endpoint: process.env.LEVO_COLLECTOR_URL, + }, + ], + }, +}); + +const response = await portkey.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello!' }], +}); +``` + +### Using with Portkey SDK (Python) + +```python +from portkey_ai import Portkey +import os + +portkey = Portkey( + api_key=os.environ["PORTKEY_API_KEY"], + config={ + "provider": "openai", + "api_key": os.environ["OPENAI_API_KEY"], + "after_request_hooks": [ + { + "id": "levo.observability", + "organizationId": os.environ["LEVO_ORG_ID"], + "endpoint": os.environ["LEVO_COLLECTOR_URL"], + } + ], + }, +) + +response = portkey.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": "Hello!"}] +) +``` + +### Multi-Tenant Configuration + +For multi-tenant deployments, include both `organizationId` and `workspaceId`: + +```json +{ + "provider": "anthropic", + "api_key": "your-anthropic-key", + "after_request_hooks": [ + { + "id": "levo.observability", + "organizationId": "org-123", + "workspaceId": "workspace-456", + "endpoint": "http://levo-collector:4318/v1/traces" + } + ] +} +``` + +## Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `organizationId` | string | **Yes** | - | Your Levo AI organization ID. Used for routing traces to the correct tenant. | +| `endpoint` | string | No | `http://localhost:4318/v1/traces` | URL of your Levo AI Collector OTLP HTTP endpoint. | +| `workspaceId` | string | No | - | Workspace ID for routing traces within your organization (multi-tenant). | +| `timeout` | number | No | `5000` | Request timeout in milliseconds for sending traces to collector. | +| `headers` | string | No | - | Additional HTTP headers as JSON string (e.g., `'{"Authorization": "Bearer token"}'`). | + +## How It Works + +1. **Capture**: The plugin captures request and response data from Portkey Gateway after each LLM API call +2. **Transform**: Request and response data is converted to OpenTelemetry (OTLP) trace format with two spans: `REQUEST_ROOT` and `RESPONSE_ROOT` +3. **Enrich**: Traces are enriched with metadata including provider, model, token usage, and custom tags +4. **Route**: Traces are sent to your Levo AI Collector with organization/workspace headers for multi-tenant routing +5. **Analyze**: Levo AI Collector processes traces for security analysis, anomaly detection, and observability + +## Data Captured + +The plugin captures comprehensive data for each LLM API call: + +**Request Data:** +- HTTP method, URL, and headers +- Request body (prompt, messages, parameters) +- Provider and model information +- Request metadata and tags + +**Response Data:** +- HTTP status code and headers +- Response body (completion, choices, usage) +- Token counts (input, output, total) +- Latency and timing information + +**Trace Correlation:** +- Trace ID for distributed tracing +- Span IDs for request and response +- Parent span ID for trace hierarchy + +## Limitations + +- **Streaming Responses**: Streaming responses are not currently supported; plugin will skip streaming requests +- **Success Only**: Plugin executes only on successful LLM responses (HTTP 200); errors are logged separately +- **Collector Required**: Requires Levo AI Collector to be deployed and accessible from Portkey Gateway +- **OTLP Format**: Collector must support OTLP HTTP/JSON protocol on the configured endpoint + +## Troubleshooting + +### Plugin Not Sending Traces + +1. Verify Levo AI Collector is running and accessible +2. Check network connectivity: `curl http://levo-collector:4318/v1/traces` +3. Verify `organizationId` is correct +4. Check Portkey Gateway logs for plugin execution errors + +### Traces Not Appearing in Levo AI + +1. Verify collector is configured to route OTLP traces correctly +2. Check collector logs for processing errors +3. Verify organization ID matches your Levo AI account +4. Ensure collector can reach Levo AI backend + +### Performance Issues + +1. Increase `timeout` parameter if collector is slow +2. Check network latency between gateway and collector +3. Monitor collector resource usage (CPU, memory) +4. Consider deploying collector closer to gateway + +## Support + +- **Levo AI Documentation**: [https://docs.levo.ai](https://docs.levo.ai) +- **Levo AI Support**: [support@levo.ai](mailto:support@levo.ai) +- **Portkey Gateway**: [https://portkey.ai](https://portkey.ai) +- **GitHub Issues**: [https://github.com/Portkey-AI/gateway/issues](https://github.com/Portkey-AI/gateway/issues) diff --git a/plugins/levo-ai/index.ts b/plugins/levo-ai/index.ts new file mode 100644 index 000000000..c75f80cab --- /dev/null +++ b/plugins/levo-ai/index.ts @@ -0,0 +1,409 @@ +import { + HookEventType, + PluginContext, + PluginHandler, + PluginParameters, +} from '../types'; +import { post } from '../utils'; + +// Declare crypto for TypeScript +declare const crypto: { + getRandomValues: (array: Uint8Array) => Uint8Array; + randomUUID?: () => string; +}; + +/** + * Converts HookSpanContext to OTLP ResourceSpans format + * + * Collector exporter expects EXACTLY 2 spans per scope: + * 1. REQUEST_ROOT span with levo.span.type = "REQUEST_ROOT" + * 2. RESPONSE_ROOT span with levo.span.type = "RESPONSE_ROOT" + * + * See: collector-components/exporter/levosatexporter/levoai_satellite.go:279-316 + * See: collector-components/receiver/f5ltmlogsreceiver/encoder.go:60,92 + */ +function convertToOTLP(context: PluginContext, hookSpanId: string): any { + const now = Date.now(); + const nowNano = now * 1000000; // Convert to nanoseconds + + // Generate trace ID (same for both spans) + const seed = + hookSpanId || + crypto.randomUUID?.() || + `trace-${Date.now()}-${Math.random()}`; + const traceId = + context.metadata?.traceId || + context.metadata?.['x-portkey-trace-id'] || + context.request?.headers?.['x-portkey-trace-id'] || + generateTraceIdFromString(seed); + + // Generate span IDs + const requestSpanId = generateSpanId(); + const responseSpanId = generateSpanId(); + + const parentSpanId = + context.metadata?.parentSpanId || + context.metadata?.['x-portkey-parent-span-id'] || + context.request?.headers?.['x-portkey-parent-span-id']; + + // Span kind: 3 = CLIENT (Portkey is a client to LLM providers) + const spanKind = 3; + + // ============ REQUEST SPAN (REQUEST_ROOT) ============ + const requestAttributes: any[] = [ + { + key: 'levo.span.type', + value: { stringValue: 'REQUEST_ROOT' }, + }, + { + key: 'http.method', + value: { stringValue: 'POST' }, + }, + { + key: 'http.target', + value: { + stringValue: `/v1/${context.requestType || 'chat/completions'}`, + }, + }, + { + key: 'http.scheme', + value: { stringValue: 'https' }, + }, + ]; + + // Request headers (as arrays like f5ltmlogsreceiver does) + if (context.request?.headers) { + const headerNames: any[] = []; + const headerValues: any[] = []; + Object.entries(context.request.headers).forEach(([name, value]) => { + headerNames.push({ stringValue: name }); + headerValues.push({ stringValue: String(value) }); + }); + requestAttributes.push({ + key: 'levo.http.request.header.names', + value: { arrayValue: { values: headerNames } }, + }); + requestAttributes.push({ + key: 'levo.http.request.header.values', + value: { arrayValue: { values: headerValues } }, + }); + } + + // Request body (as array like f5ltmlogsreceiver does) + const requestBodyBuffers: any[] = []; + if (context.request?.json) { + requestBodyBuffers.push({ + stringValue: JSON.stringify(context.request.json), + }); + } else if (context.request?.text) { + requestBodyBuffers.push({ stringValue: context.request.text }); + } + if (requestBodyBuffers.length > 0) { + requestAttributes.push({ + key: 'levo.http.request.body.buffers', + value: { arrayValue: { values: requestBodyBuffers } }, + }); + } + + // Add provider and request type + if (context.provider) { + requestAttributes.push({ + key: 'levo.provider', + value: { stringValue: context.provider }, + }); + } + if (context.requestType) { + requestAttributes.push({ + key: 'levo.request.type', + value: { stringValue: context.requestType }, + }); + } + + // ============ RESPONSE SPAN (RESPONSE_ROOT) ============ + const responseAttributes: any[] = [ + { + key: 'levo.span.type', + value: { stringValue: 'RESPONSE_ROOT' }, + }, + ]; + + // HTTP status code + const statusCode = context.response?.statusCode || 200; + responseAttributes.push({ + key: 'http.status_code', + value: { intValue: statusCode.toString() }, + }); + + // Response headers (as arrays) + if (context.response?.headers) { + const headerNames: any[] = []; + const headerValues: any[] = []; + Object.entries(context.response.headers).forEach(([name, value]) => { + headerNames.push({ stringValue: name }); + headerValues.push({ stringValue: String(value) }); + }); + responseAttributes.push({ + key: 'levo.http.response.header.names', + value: { arrayValue: { values: headerNames } }, + }); + responseAttributes.push({ + key: 'levo.http.response.header.values', + value: { arrayValue: { values: headerValues } }, + }); + } + + // Response body (as array) + const responseBodyBuffers: any[] = []; + if (context.response?.json) { + responseBodyBuffers.push({ + stringValue: JSON.stringify(context.response.json), + }); + } else if (context.response?.text) { + responseBodyBuffers.push({ stringValue: context.response.text }); + } + if (responseBodyBuffers.length > 0) { + responseAttributes.push({ + key: 'levo.http.response.body.buffers', + value: { arrayValue: { values: responseBodyBuffers } }, + }); + } + + // Add model and usage info to response span + if (context.response?.json?.model) { + responseAttributes.push({ + key: 'gen_ai.response.model', + value: { stringValue: context.response.json.model }, + }); + } + if (context.response?.json?.usage) { + const usage = context.response.json.usage; + if (usage.prompt_tokens) { + responseAttributes.push({ + key: 'gen_ai.usage.prompt_tokens', + value: { intValue: usage.prompt_tokens.toString() }, + }); + } + if (usage.completion_tokens) { + responseAttributes.push({ + key: 'gen_ai.usage.completion_tokens', + value: { intValue: usage.completion_tokens.toString() }, + }); + } + if (usage.total_tokens) { + responseAttributes.push({ + key: 'gen_ai.usage.total_tokens', + value: { intValue: usage.total_tokens.toString() }, + }); + } + } + + // Build REQUEST_ROOT span + const requestSpan: any = { + traceId: traceId, + spanId: requestSpanId, + name: `${context.provider}/${context.requestType}/request`, + kind: spanKind, + startTimeUnixNano: nowNano.toString(), + endTimeUnixNano: nowNano.toString(), + attributes: requestAttributes, + status: { code: 'STATUS_CODE_OK' }, + }; + + // Build RESPONSE_ROOT span + const responseSpan: any = { + traceId: traceId, // Same trace ID + spanId: responseSpanId, + name: `${context.provider}/${context.requestType}/response`, + kind: spanKind, + startTimeUnixNano: nowNano.toString(), + endTimeUnixNano: nowNano.toString(), + attributes: responseAttributes, + status: + statusCode === 200 + ? { code: 'STATUS_CODE_OK' } + : { code: 'STATUS_CODE_ERROR', message: `HTTP ${statusCode}` }, + }; + + if (parentSpanId) { + requestSpan.parentSpanId = parentSpanId; + responseSpan.parentSpanId = parentSpanId; + } + + // Build OTLP trace structure with 2 spans in same scope + return { + resourceSpans: [ + { + resource: { + attributes: [ + { + key: 'service.name', + value: { stringValue: 'portkey-gateway' }, + }, + { + key: 'service.version', + value: { stringValue: '1.0.0' }, + }, + ], + }, + scopeSpans: [ + { + scope: { + name: 'levo-portkey-observability', + version: '1.0.0', + }, + spans: [requestSpan, responseSpan], // EXACTLY 2 spans as exporter expects + }, + ], + }, + ], + }; +} + +/** + * Generate a deterministic 32-character hex trace ID from a string seed + * Ensures same trace ID is generated from same seed (e.g., hookSpanId) + */ +function generateTraceIdFromString(seed: string): string { + if (!seed || typeof seed !== 'string') { + seed = `trace-${Date.now()}-${Math.random()}`; + } + let hash = 0; + for (let i = 0; i < seed.length; i++) { + const char = seed.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) { + bytes[i] = (hash >> (i * 2)) & 0xff; + } + + for (let i = 0; i < seed.length && i < 16; i++) { + bytes[i] = (bytes[i] + seed.charCodeAt(i)) & 0xff; + } + + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * Generate a random 32-character hex trace ID (OTLP format) + */ +function generateTraceId(): string { + const bytes = new Uint8Array(16); + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(bytes); + } else { + // Fallback for environments without crypto + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * Generate a 16-character hex span ID (OTLP format) + */ +function generateSpanId(): string { + const bytes = new Uint8Array(8); + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + crypto.getRandomValues(bytes); + } else { + // Fallback for environments without crypto + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +export const handler: PluginHandler = async ( + context: PluginContext, + parameters: PluginParameters, + eventType: HookEventType +) => { + let error = null; + let verdict = true; + let data = null; + + try { + // CRITICAL: Only execute on afterRequestHook + // This is when we have BOTH complete request AND response data + if (eventType !== 'afterRequestHook') { + return { + error: null, + verdict: true, + data: { skipped: true, reason: 'Only runs on afterRequestHook' }, + }; + } + + // WARNING: In streaming mode, responseJSON is null + // This plugin will skip streaming requests + if (context.request?.isStreamingRequest && !context.response?.json) { + return { + error: null, + verdict: true, + data: { + skipped: true, + reason: 'Streaming response not fully buffered', + }, + }; + } + + const endpoint = parameters.endpoint || 'http://localhost:4318/v1/traces'; + + // Convert context to single complete OTLP span + // hookSpanId might not be on context, use a fallback + const hookSpanId = + context.hookSpanId || + context.metadata?.hookSpanId || + `span-${Date.now()}`; + const otlpTrace = convertToOTLP(context, hookSpanId); + + // Parse custom headers if provided + const headers: Record = parameters?.headers + ? JSON.parse(parameters.headers) + : {}; + + // Set OTLP required headers + headers['Content-Type'] = 'application/json'; + + // Send organization ID exactly as sensors do + // Sensors send: x-levo-organization-id header (see ebpf_sensor.cpp:392) + if (!parameters?.organizationId) { + throw new Error( + 'organizationId is required. Provide it in plugin parameters.' + ); + } + headers['x-levo-organization-id'] = String(parameters.organizationId); + + // Send workspace ID if provided (sensors also have workspace ID in config) + if (parameters?.workspaceId) { + headers['x-levo-workspace-id'] = String(parameters.workspaceId); + } + + // Send to OTLP collector + await post(endpoint, otlpTrace, { headers }, parameters.timeout || 5000); + + verdict = true; + data = { + message: `Sent OTLP trace to ${endpoint}`, + traceId: otlpTrace.resourceSpans[0].scopeSpans[0].spans[0].traceId, + requestSpanId: otlpTrace.resourceSpans[0].scopeSpans[0].spans[0].spanId, + responseSpanId: otlpTrace.resourceSpans[0].scopeSpans[0].spans[1].spanId, + }; + } catch (e: any) { + delete e.stack; + error = e; + verdict = false; + } + + return { error, verdict, data }; +}; diff --git a/plugins/levo-ai/manifest.json b/plugins/levo-ai/manifest.json new file mode 100644 index 000000000..7c4794c38 --- /dev/null +++ b/plugins/levo-ai/manifest.json @@ -0,0 +1,77 @@ +{ + "id": "levo", + "name": "Levo AI", + "description": "Observability integration for Levo AI platform", + "functions": [ + { + "name": "Send Observability Traces", + "id": "observability", + "type": "guardrail", + "supportedHooks": ["afterRequestHook"], + "description": [ + { + "type": "subHeading", + "text": "Sends request/response traces to Levo AI Collector for observability and monitoring." + } + ], + "parameters": { + "type": "object", + "required": ["organizationId"], + "properties": { + "endpoint": { + "type": "string", + "label": "Collector Endpoint", + "description": [ + { + "type": "subHeading", + "text": "Levo AI Collector endpoint URL (default: http://localhost:4318/v1/traces)" + } + ], + "default": "http://localhost:4318/v1/traces" + }, + "organizationId": { + "type": "string", + "label": "Organization ID", + "description": [ + { + "type": "subHeading", + "text": "Levo organization ID (required). Sent as x-levo-organization-id header to collector for routing." + } + ] + }, + "workspaceId": { + "type": "string", + "label": "Workspace ID", + "description": [ + { + "type": "subHeading", + "text": "Levo workspace ID (optional). Sent as x-levo-workspace-id header to collector." + } + ] + }, + "headers": { + "type": "string", + "label": "Headers (JSON)", + "description": [ + { + "type": "subHeading", + "text": "Additional HTTP headers as JSON string (e.g., '{\"Authorization\": \"Bearer token\"}'). Note: x-levo-organization-id will be automatically added if organizationId is provided." + } + ] + }, + "timeout": { + "type": "number", + "label": "Timeout (ms)", + "description": [ + { + "type": "subHeading", + "text": "Request timeout in milliseconds (default: 5000)" + } + ], + "default": 5000 + } + } + } + } + ] +} diff --git a/src/handlers/responseHandlers.ts b/src/handlers/responseHandlers.ts index ee1bf5398..efe7eb9ec 100644 --- a/src/handlers/responseHandlers.ts +++ b/src/handlers/responseHandlers.ts @@ -230,10 +230,19 @@ export async function afterRequestHookHandler( try { const hooksManager = c.get('hooksManager'); + // Convert Headers object to plain Record + const responseHeaders: Record = {}; + if (response.headers) { + response.headers.forEach((value: string, key: string) => { + responseHeaders[key] = value; + }); + } + hooksManager.setSpanContextResponse( hookSpanId, responseJSON, - response.status + response.status, + responseHeaders ); if (retryAttemptsMade > 0) { diff --git a/src/middlewares/hooks/index.ts b/src/middlewares/hooks/index.ts index edbcfadd3..e1824c974 100644 --- a/src/middlewares/hooks/index.ts +++ b/src/middlewares/hooks/index.ts @@ -114,18 +114,51 @@ export class HookSpan { hooks: HookObject[], eventType: EventType ): HookObject[] { - return hooks.map((hook) => ({ ...hook, eventType })); + return hooks.map((hook) => { + // Normalize hooks from config: if hook has an 'id' that contains '.' (plugin format like 'levo.observability') + // but no 'checks' array, convert it to proper HookObject structure + if ( + hook.id && + hook.id.includes('.') && + (!hook.checks || hook.checks.length === 0) + ) { + const { id, type, ...rest } = hook; + // Extract plugin ID (e.g., 'levo.observability') + const pluginId = id; + // Default to GUARDRAIL if type not specified + const hookType = type || HookType.GUARDRAIL; + // All other properties become parameters + return { + id: `hook_${Math.random().toString(36).substring(2, 9)}`, + type: hookType, + checks: [ + { + id: pluginId, + parameters: rest, + is_enabled: true, + }, + ], + eventType, + async: hook.async || false, + deny: hook.deny || false, + } as HookObject; + } + // Already normalized hook, just add eventType + return { ...hook, eventType }; + }); } public setContextResponse( responseJSON: Record, - responseStatus: number + responseStatus: number, + responseHeaders?: Record ): void { const responseText = this.extractResponseText(responseJSON); this.context.response = { json: responseJSON, text: responseText, statusCode: responseStatus, + headers: responseHeaders || {}, isTransformed: this.context.response.isTransformed || false, }; } @@ -237,10 +270,11 @@ export class HooksManager { public setSpanContextResponse( spanId: string, responseJson: Record, - responseStatusCode: number + responseStatusCode: number, + responseHeaders?: Record ): void { const span = this.getSpan(spanId); - span.setContextResponse(responseJson, responseStatusCode); + span.setContextResponse(responseJson, responseStatusCode, responseHeaders); } public async executeHooks( @@ -252,6 +286,20 @@ export class HooksManager { const hooksToExecute = this.getHooksToExecute(span, eventTypePresets); + console.log( + `[HooksManager] executeHooks: spanId=${spanId}, eventTypePresets=${eventTypePresets.join(',')}, hooksToExecute=${hooksToExecute.length}` + ); + if (hooksToExecute.length > 0) { + console.log( + `[HooksManager] Hooks to execute:`, + hooksToExecute.map((h) => ({ + id: h.id, + type: h.type, + checks: h.checks?.length || 0, + })) + ); + } + if (hooksToExecute.length === 0) { return { results: [], shouldDeny: false }; } @@ -335,14 +383,30 @@ export class HooksManager { let checkResults: GuardrailCheckResult[] = []; const createdAt = new Date(); + console.log( + `[HooksManager] executeEachHook: id=${hook.id}, type=${hook.type}, checks=${hook.checks?.length || 0}, eventType=${hook.eventType}` + ); + if (!hook.checks || hook.checks.length === 0) { + console.log( + `[HooksManager] ⚠️ Hook ${hook.id} has no checks array! Hook structure:`, + JSON.stringify(hook, null, 2) + ); + } + if (this.shouldSkipHook(span, hook)) { return { ...hookResult, skipped: true }; } if (hook.type === HookType.MUTATOR && hook.checks) { + // Add hookSpanId to context for plugins that need it + const contextWithSpanId = { + ...span.getContext(), + hookSpanId: spanId, + }; + for (const check of hook.checks) { const result = await this.executeFunction( - span.getContext(), + contextWithSpanId, check, hook.eventType, options @@ -363,13 +427,19 @@ export class HooksManager { } if (hook.type === HookType.GUARDRAIL && hook.checks) { + // Add hookSpanId to context for plugins that need it + const contextWithSpanId = { + ...span.getContext(), + hookSpanId: spanId, + }; + if (hook.sequential) { // execute checks sequentially and update the context after each check for (const check of hook.checks.filter( (check: Check) => check.is_enabled !== false )) { const result = await this.executeFunction( - span.getContext(), + contextWithSpanId, check, hook.eventType, options @@ -388,12 +458,18 @@ export class HooksManager { checkResults.push(result); } } else { + // Add hookSpanId to context for plugins that need it + const contextWithSpanId = { + ...span.getContext(), + hookSpanId: spanId, + }; + checkResults = await Promise.all( hook.checks .filter((check: Check) => check.is_enabled !== false) .map((check: Check) => this.executeFunction( - span.getContext(), + contextWithSpanId, check, hook.eventType, options diff --git a/src/middlewares/hooks/types.ts b/src/middlewares/hooks/types.ts index a2b05ef78..cd2853e42 100644 --- a/src/middlewares/hooks/types.ts +++ b/src/middlewares/hooks/types.ts @@ -38,6 +38,7 @@ export interface HookSpanContextResponse { text: string; json: any; statusCode: number | null; + headers?: Record; // Add headers field isTransformed: boolean; }