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
7 changes: 7 additions & 0 deletions javascript/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ const envSchema = z.object({
*/
LANGWATCH_API_KEY: z.string().optional(),

/**
* LangWatch project ID. When set alongside LANGWATCH_API_KEY, requests are
* scoped to this project via the X-Project-Id header. Required for API keys
* that are not bound to a single project.
*/
LANGWATCH_PROJECT_ID: z.string().optional(),

/**
* LangWatch endpoint URL for event reporting.
* Defaults to the production LangWatch endpoint.
Expand Down
120 changes: 118 additions & 2 deletions javascript/src/events/__tests__/event-reporter.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

import { EventReporter } from "../event-reporter";
import { ScenarioEventType, type ScenarioEvent } from "../schema";
import {
ScenarioEventType,
type ScenarioEvent,
type ScenarioRunStartedEvent,
} from "../schema";

vi.mock("../event-alert-message-logger", () => ({
EventAlertMessageLogger: vi.fn().mockImplementation(function (this: unknown) {
return { handleGreeting: vi.fn() };
}),
}));

/**
* Build a MESSAGE_SNAPSHOT event whose message carries ARRAY content with an
Expand Down Expand Up @@ -46,6 +56,29 @@ function makeAudioSnapshotEvent(): ScenarioEvent {
} as unknown as ScenarioEvent;
}

function makeEvent(): ScenarioRunStartedEvent {
return {
type: ScenarioEventType.RUN_STARTED,
batchRunId: "batch-1",
scenarioId: "scenario-1",
scenarioRunId: "run-1",
scenarioSetId: "default",
timestamp: Date.now(),
metadata: { name: "test-name", description: "test-description" },
};
}

function mockOkFetch() {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ url: "https://app.langwatch.ai/scenario/run-1" }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

/**
* `processEventForApi` is private — exercise it through bracket access. This is
* the transform applied to every event immediately before the POST body is
Expand Down Expand Up @@ -115,3 +148,86 @@ describe("EventReporter.processEventForApi", () => {
expect(processed.messages[0].content).toBe("just text");
});
});

describe("EventReporter", () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("sends Authorization: Bearer and no X-Auth-Token", async () => {
const fetchMock = mockOkFetch();
const reporter = new EventReporter({
endpoint: "https://app.langwatch.ai",
apiKey: "test-api-key",
});

await reporter.postEvent(makeEvent());

expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://app.langwatch.ai/api/scenario-events");
const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer test-api-key");
expect(headers["Content-Type"]).toBe("application/json");
expect(headers["X-Auth-Token"]).toBeUndefined();
expect(headers["X-Project-Id"]).toBeUndefined();
});

it("sends X-Project-Id when projectId is configured", async () => {
const fetchMock = mockOkFetch();
const reporter = new EventReporter({
endpoint: "https://app.langwatch.ai",
apiKey: "test-api-key",
projectId: "project_xxx",
});

await reporter.postEvent(makeEvent());

const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer test-api-key");
expect(headers["X-Project-Id"]).toBe("project_xxx");
});

it("omits X-Project-Id when projectId is an empty string", async () => {
const fetchMock = mockOkFetch();
const reporter = new EventReporter({
endpoint: "https://app.langwatch.ai",
apiKey: "test-api-key",
projectId: "",
});

await reporter.postEvent(makeEvent());

const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
expect(headers["X-Project-Id"]).toBeUndefined();
});

it("skips POST when apiKey is empty (avoids 401 storm)", async () => {
const fetchMock = mockOkFetch();
const reporter = new EventReporter({
endpoint: "https://app.langwatch.ai",
apiKey: undefined,
});

const result = await reporter.postEvent(makeEvent());

expect(fetchMock).not.toHaveBeenCalled();
expect(result).toEqual({});
});

it("returns setUrl from a successful response", async () => {
mockOkFetch();
const reporter = new EventReporter({
endpoint: "https://app.langwatch.ai",
apiKey: "test-api-key",
});

const result = await reporter.postEvent(makeEvent());

expect(result.setUrl).toBe("https://app.langwatch.ai/scenario/run-1");
});
});
15 changes: 8 additions & 7 deletions javascript/src/events/event-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,15 @@ export class EventReporter {
this.logger.debug(`[${event.type}] Posting event`, { event });
const processedEvent = this.processEventForApi(event);

const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
};
if (this.projectId) {
headers["X-Project-Id"] = this.projectId;
}

try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Auth-Token": this.apiKey,
};
if (this.projectId) {
headers["X-Project-Id"] = this.projectId;
}
const response = await fetch(this.eventsEndpoint.href, {
method: "POST",
body: JSON.stringify(processedEvent),
Expand Down
5 changes: 4 additions & 1 deletion javascript/src/runner/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,10 @@ export async function run(cfg: ScenarioConfig, options?: RunOptions): Promise<Sc
eventBus = new EventBus({
endpoint: options?.langwatch?.endpoint ?? cfg.langwatch?.endpoint ?? envConfig.LANGWATCH_ENDPOINT,
apiKey: options?.langwatch?.apiKey ?? cfg.langwatch?.apiKey ?? envConfig.LANGWATCH_API_KEY,
projectId: options?.langwatch?.projectId ?? cfg.langwatch?.projectId,
projectId:
options?.langwatch?.projectId ??
cfg.langwatch?.projectId ??
envConfig.LANGWATCH_PROJECT_ID,
});
eventBus.listen();

Expand Down
29 changes: 15 additions & 14 deletions python/scenario/_events/event_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,21 @@ class EventReporter:
api_key (str, optional): Override API key. Resolution order: explicit
arg → LANGWATCH_API_KEY env var → langwatch.client.Client._api_key
(set by langwatch.setup(api_key=...)).
project_id (str, optional): Override LangWatch project ID. When set
(alongside api_key), requests carry the X-Project-Id header. Falls
back to LANGWATCH_PROJECT_ID env var.

Example:
# Using environment variables (LANGWATCH_ENDPOINT, LANGWATCH_API_KEY)
# Using environment variables (LANGWATCH_ENDPOINT, LANGWATCH_API_KEY,
# LANGWATCH_PROJECT_ID)
reporter = EventReporter()

# Or rely on langwatch.setup(api_key=...) called earlier in the process
reporter = EventReporter()

# Override specific values
reporter = EventReporter(endpoint="https://langwatch.yourdomain.com")
reporter = EventReporter(api_key="your-api-key")
reporter = EventReporter(api_key="your-api-key", project_id="project_xxx")
"""

# Process-wide flag: emit the "no api_key configured" warning at most once
Expand All @@ -99,6 +103,7 @@ def __init__(
self,
endpoint: Optional[str] = None,
api_key: Optional[str] = None,
project_id: Optional[str] = None,
):
# Load settings from environment variables
langwatch_settings = LangWatchSettings()
Expand All @@ -113,6 +118,7 @@ def __init__(
or langwatch_settings.api_key
or _resolve_langwatch_client_api_key()
)
self.project_id = project_id or langwatch_settings.project_id
self.logger = logging.getLogger(__name__)
self.event_alert_message_logger = EventAlertMessageLogger()

Expand Down Expand Up @@ -161,22 +167,17 @@ async def post_event(self, event: ScenarioEvent) -> Dict[str, Any]:
self.logger.debug(
f"[{event_type}] POST {url} payload keys={list(payload.keys())} ({event.scenario_run_id})"
)
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
if self.project_id:
headers["X-Project-Id"] = self.project_id
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.post(
url,
json=payload,
headers={
"Content-Type": "application/json",
# Send credentials as both Authorization: Bearer (RFC
# 6750) and X-Auth-Token (legacy). Some corporate
# proxies strip non-standard headers like X-Auth-Token
# while preserving Authorization, so dual-emit makes
# the SDK robust to that path. The server's auth
# middleware accepts either; if both are present
# Bearer wins by middleware priority.
"Authorization": f"Bearer {self.api_key}",
"X-Auth-Token": self.api_key,
},
headers=headers,
timeout=httpx.Timeout(30.0),
)
self.logger.info(
Expand Down
17 changes: 14 additions & 3 deletions python/scenario/config/langwatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,32 @@ class LangWatchSettings(BaseSettings):
Attributes:
endpoint: LangWatch API endpoint URL
api_key: API key for LangWatch authentication
project_id: Optional LangWatch project ID. When set, requests scope to
this project via the X-Project-Id header. Required for API keys
that are not bound to a single project.

Environment Variables:
LANGWATCH_ENDPOINT: LangWatch API endpoint (defaults to https://app.langwatch.ai)
LANGWATCH_API_KEY: API key for authentication (defaults to empty string)
LANGWATCH_PROJECT_ID: Project ID for project-scoped requests (defaults to empty string)

Example:
```
# Using environment variables
# export LANGWATCH_ENDPOINT="https://app.langwatch.ai"
# export LANGWATCH_API_KEY="your-api-key"
# export LANGWATCH_PROJECT_ID="project_xxx"

settings = LangWatchSettings()
print(settings.endpoint) # https://app.langwatch.ai
print(settings.api_key) # your-api-key
print(settings.endpoint) # https://app.langwatch.ai
print(settings.api_key) # your-api-key
print(settings.project_id) # project_xxx

# Or override programmatically
settings = LangWatchSettings(
endpoint="https://custom.langwatch.ai",
api_key="your-api-key"
api_key="your-api-key",
project_id="project_xxx",
)
```
"""
Expand All @@ -49,3 +56,7 @@ class LangWatchSettings(BaseSettings):
description="LangWatch API endpoint URL",
)
api_key: str = Field(default="", description="API key for LangWatch authentication")
project_id: str = Field(
default="",
description="LangWatch project ID, sent as X-Project-Id when present",
)
Loading
Loading