diff --git a/javascript/src/config/env.ts b/javascript/src/config/env.ts index 787fa18f9..bf62dc9dc 100644 --- a/javascript/src/config/env.ts +++ b/javascript/src/config/env.ts @@ -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. diff --git a/javascript/src/events/__tests__/event-reporter.test.ts b/javascript/src/events/__tests__/event-reporter.test.ts index bc4c2100d..b883cccce 100644 --- a/javascript/src/events/__tests__/event-reporter.test.ts +++ b/javascript/src/events/__tests__/event-reporter.test.ts @@ -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 @@ -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 @@ -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; + 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; + 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; + 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"); + }); +}); diff --git a/javascript/src/events/event-reporter.ts b/javascript/src/events/event-reporter.ts index bc606f39f..4ad533690 100644 --- a/javascript/src/events/event-reporter.ts +++ b/javascript/src/events/event-reporter.ts @@ -40,14 +40,15 @@ export class EventReporter { this.logger.debug(`[${event.type}] Posting event`, { event }); const processedEvent = this.processEventForApi(event); + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }; + if (this.projectId) { + headers["X-Project-Id"] = this.projectId; + } + try { - const headers: Record = { - "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), diff --git a/javascript/src/runner/run.ts b/javascript/src/runner/run.ts index e74610741..3b950fd65 100644 --- a/javascript/src/runner/run.ts +++ b/javascript/src/runner/run.ts @@ -155,7 +155,10 @@ export async function run(cfg: ScenarioConfig, options?: RunOptions): Promise 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( diff --git a/python/scenario/config/langwatch.py b/python/scenario/config/langwatch.py index e593238e0..25c2674d6 100644 --- a/python/scenario/config/langwatch.py +++ b/python/scenario/config/langwatch.py @@ -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", ) ``` """ @@ -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", + ) diff --git a/python/tests/test_event_reporter.py b/python/tests/test_event_reporter.py index 5bffccfc6..024beba54 100644 --- a/python/tests/test_event_reporter.py +++ b/python/tests/test_event_reporter.py @@ -64,14 +64,12 @@ async def test_post_event_sends_correct_request(caplog: LogCaptureFixture) -> No assert route.called request: httpx.Request = route.calls[0].request - # Dual-emit: both Authorization: Bearer (preferred by RFC 6750 + most - # corporate proxies) and X-Auth-Token (legacy compat). Skai's traces - # arrive via Authorization but their scenario events POSTs were getting - # X-Auth-Token stripped at their network boundary; dual-emit closes - # that gap without changing customer config. assert request.headers["Authorization"] == f"Bearer {api_key}" - assert request.headers["X-Auth-Token"] == api_key + assert "X-Auth-Token" not in request.headers assert request.headers["Content-Type"] == "application/json" + # Without project_id, no X-Project-Id header is sent — the API key is + # assumed to be project-bound. + assert "X-Project-Id" not in request.headers assert ( b'"type": "SCENARIO_RUN_STARTED"' in request.content or b'"type":"SCENARIO_RUN_STARTED"' in request.content @@ -79,6 +77,78 @@ async def test_post_event_sends_correct_request(caplog: LogCaptureFixture) -> No assert any("POST response status: 200" in m for m in caplog.messages) +@pytest.mark.asyncio +async def test_post_event_includes_x_project_id_when_configured() -> None: + """When project_id is set, X-Project-Id is sent alongside Authorization. + + This supports API keys that are not bound to a single project — the + project_id tells the server which project to scope the request to. + """ + endpoint = "https://app.langwatch.ai" + api_key = "test-api-key" + project_id = "project_xxx" + event = _make_event() + + reporter = EventReporter( + endpoint=endpoint, api_key=api_key, project_id=project_id + ) + + with respx.mock as mock: + route = mock.post(f"{endpoint}/api/scenario-events").respond( + 200, json={"ok": True} + ) + await reporter.post_event(event) + + assert route.called + request: httpx.Request = route.calls[0].request + assert request.headers["Authorization"] == f"Bearer {api_key}" + assert request.headers["X-Project-Id"] == project_id + + +@pytest.mark.asyncio +async def test_project_id_falls_back_to_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LANGWATCH_PROJECT_ID", "project_from_env") + + reporter = EventReporter( + endpoint="https://app.langwatch.ai", api_key="test-api-key" + ) + event = _make_event() + + with respx.mock as mock: + route = mock.post("https://app.langwatch.ai/api/scenario-events").respond( + 200, json={"ok": True} + ) + await reporter.post_event(event) + + assert route.called + request: httpx.Request = route.calls[0].request + assert request.headers["X-Project-Id"] == "project_from_env" + + +@pytest.mark.asyncio +async def test_explicit_project_id_wins_over_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("LANGWATCH_PROJECT_ID", "project_from_env") + + reporter = EventReporter( + endpoint="https://app.langwatch.ai", + api_key="test-api-key", + project_id="project_explicit", + ) + event = _make_event() + + with respx.mock as mock: + route = mock.post("https://app.langwatch.ai/api/scenario-events").respond( + 200, json={"ok": True} + ) + await reporter.post_event(event) + + assert route.called + request: httpx.Request = route.calls[0].request + assert request.headers["X-Project-Id"] == "project_explicit" + + @pytest.mark.asyncio async def test_inherits_api_key_from_langwatch_setup( monkeypatch: pytest.MonkeyPatch, @@ -107,7 +177,7 @@ async def test_inherits_api_key_from_langwatch_setup( assert route.called request: httpx.Request = route.calls[0].request - assert request.headers["X-Auth-Token"] == "sk-lw-from-langwatch-setup" + assert request.headers["Authorization"] == "Bearer sk-lw-from-langwatch-setup" @pytest.mark.asyncio @@ -135,7 +205,7 @@ async def test_constructor_api_key_wins_over_env_and_langwatch_state( assert route.called request: httpx.Request = route.calls[0].request - assert request.headers["X-Auth-Token"] == "sk-lw-explicit" + assert request.headers["Authorization"] == "Bearer sk-lw-explicit" @pytest.mark.asyncio @@ -160,7 +230,7 @@ async def test_env_var_wins_over_langwatch_state_when_no_explicit_key( assert route.called request: httpx.Request = route.calls[0].request - assert request.headers["X-Auth-Token"] == "sk-lw-from-env" + assert request.headers["Authorization"] == "Bearer sk-lw-from-env" @pytest.mark.asyncio @@ -170,7 +240,7 @@ async def test_skips_post_when_api_key_unavailable_everywhere( _reset_langwatch_client_state: None, ) -> None: """When no api_key is set anywhere, never POST. Customer ran into hundreds of - thousands of 401s/day because empty `X-Auth-Token: ""` was still being sent + thousands of 401s/day because an empty bearer token was still being sent on every event. Skipping silently is correct — the greeting message in EventAlertMessageLogger already directs the user to set LANGWATCH_API_KEY. """