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
57 changes: 55 additions & 2 deletions src/lib/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
*/

import { queryExploreEventsInTableFormat } from "@sentry/api";
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node-core/light";
import type { z } from "zod";

import {
DetailedLogsResponseSchema,
Expand All @@ -16,6 +19,7 @@ import {
type TraceLog,
TraceLogsResponseSchema,
} from "../../types/index.js";
import { ApiError } from "../errors.js";
import { resolveOrgRegion } from "../region.js";
import { LOG_RETENTION_PERIOD } from "../retention.js";
import { isAllDigits } from "../utils.js";
Expand Down Expand Up @@ -45,6 +49,47 @@ const LOG_FIELDS = [
"message",
];

/**
* Validate that the API returned an object before attempting Zod parsing.
* Self-hosted instances may return plain text or HTML when the logs dataset
* is unsupported or a reverse proxy intercepts the request.
*/
function assertObjectResponse(data: unknown, context: string): void {
if (typeof data !== "object" || data === null) {
throw new ApiError(
`${context}: unexpected response format`,
0,
`Expected an object but received ${typeof data}. ` +
"This may indicate an incompatible self-hosted Sentry version or a proxy interfering with the response."
);
Comment on lines +62 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The assertObjectResponse function logs a confusing error message, "received object", when the API response is null due to the typeof null === "object" JavaScript quirk.
Severity: LOW

Suggested Fix

Modify the error message generation in assertObjectResponse to handle the null case specifically. Instead of just using typeof data, check if data === null and print "null" in the message. For example: ...received ${data === null ? 'null' : typeof data}. This will provide a clear and accurate error message.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/lib/api/logs.ts#L62-L64

Potential issue: When the API response data is `null`, the `assertObjectResponse`
function correctly throws an `ApiError`. However, due to the JavaScript quirk where
`typeof null` evaluates to `"object"`, the error message becomes `"Expected an object
but received object"`. This is self-contradictory and misleading for users, especially
in self-hosted environments where a `null` response is a possible edge case. This makes
diagnosing the underlying issue more difficult.

Did we get this right? 👍 / 👎 to inform future reviews.

}
}

/**
* Safe-parse an API response with a Zod schema, throwing {@link ApiError}
* on validation failure instead of leaking a raw `ZodError`.
*/
function safeParseResponse<T>(
schema: z.ZodType<T>,
data: unknown,
context: string
): T {
assertObjectResponse(data, context);
const result = schema.safeParse(data);
if (!result.success) {
Sentry.setContext("zod_validation", {
context,
issues: result.error.issues.slice(0, 10),
});
throw new ApiError(
`${context}: unexpected response format`,
0,
result.error.message
);
}
return result.data;
}

type ListLogsOptions = {
/** Search query using Sentry query syntax */
query?: string;
Expand Down Expand Up @@ -128,7 +173,11 @@ export async function listLogs(
});

const data = unwrapResult(result, "Failed to list logs");
const logsResponse = LogsResponseSchema.parse(data);
const logsResponse = safeParseResponse(
LogsResponseSchema,
data,
"Failed to list logs"
);
return logsResponse.data;
}

Expand Down Expand Up @@ -191,7 +240,11 @@ async function getLogsBatch(
});

const data = unwrapResult(result, "Failed to get log");
const logsResponse = DetailedLogsResponseSchema.parse(data);
const logsResponse = safeParseResponse(
DetailedLogsResponseSchema,
data,
"Failed to get log"
);
return logsResponse.data;
}

Expand Down
158 changes: 158 additions & 0 deletions test/lib/api/logs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Tests for listLogs and getLogs — guards against non-object SDK responses.
*
* CLI-20C: self-hosted instances can return non-object data (plain text, HTML)
* from the /events/?dataset=logs endpoint when the logs dataset is unsupported
* or a reverse proxy intercepts the request. Previously this crashed with an
* unhandled ZodError; now it throws a descriptive ApiError.
*/

import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { getLogs, listLogs } from "../../../src/lib/api/logs.js";
import { setAuthToken } from "../../../src/lib/db/auth.js";
import { ApiError } from "../../../src/lib/errors.js";
import { mockFetch, useTestConfigDir } from "../../helpers.js";

useTestConfigDir("logs-api-test-");

let originalFetch: typeof globalThis.fetch;

beforeEach(async () => {
originalFetch = globalThis.fetch;
await setAuthToken("fake-token-for-test", 3600);
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

/**
* Mock fetch to return a fixed JSON body for all requests.
* The SDK parses the response via response.json(), so wrapping in
* JSON.stringify ensures the SDK receives the raw value as `data`.
*/
function mockOk(body: unknown) {
globalThis.fetch = mockFetch(
async () =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
}

describe("listLogs", () => {
test("returns logs when API returns a valid response", async () => {
mockOk({
data: [
{
"sentry.item_id": "log-001",
timestamp: "2025-01-30T14:32:15+00:00",
timestamp_precise: 1_770_060_419_044_800_300,
message: "Test log message",
severity: "info",
trace: "abc123def456abc123def456abc12345",
},
],
meta: { fields: {} },
});

const logs = await listLogs("test-org", "test-project");
expect(logs).toHaveLength(1);
expect(logs[0]["sentry.item_id"]).toBe("log-001");
});

test("throws ApiError when API returns a string instead of object", async () => {
mockOk("Proxy error: upstream not found");

await expect(listLogs("test-org", "test-project")).rejects.toThrow(
ApiError
);

try {
await listLogs("test-org", "test-project");
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
const apiError = error as ApiError;
expect(apiError.message).toContain("unexpected response format");
expect(apiError.detail).toContain("received string");
}
});

test("throws ApiError when API returns null", async () => {
mockOk(null);

await expect(listLogs("test-org", "test-project")).rejects.toThrow(
ApiError
);
});

test("throws ApiError when response has wrong shape", async () => {
mockOk({ wrong: "shape" });

await expect(listLogs("test-org", "test-project")).rejects.toThrow(
ApiError
);

try {
await listLogs("test-org", "test-project");
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).message).toContain(
"unexpected response format"
);
}
});
});

describe("getLogs", () => {
test("returns logs when API returns a valid detailed response", async () => {
mockOk({
data: [
{
"sentry.item_id": "log-001",
timestamp: "2025-01-30T14:32:15+00:00",
timestamp_precise: 1_770_060_419_044_800_300,
message: "Test log message",
severity: "info",
trace: "abc123def456abc123def456abc12345",
project: "test-project",
environment: "production",
release: "1.0.0",
"sdk.name": "sentry.javascript.node",
"sdk.version": "8.0.0",
span_id: "abc123def456abc1",
"code.function": "main",
"code.file.path": "/app/index.ts",
"code.line.number": "42",
"sentry.otel.kind": "INTERNAL",
"sentry.otel.status_code": "OK",
"sentry.otel.instrumentation_scope.name": "my-app",
},
],
meta: { fields: {} },
});

const logs = await getLogs("test-org", "test-project", ["log-001"]);
expect(logs).toHaveLength(1);
expect(logs[0]["sentry.item_id"]).toBe("log-001");
});

test("throws ApiError when API returns a string instead of object", async () => {
mockOk("<html><body>502 Bad Gateway</body></html>");

await expect(
getLogs("test-org", "test-project", ["log-001"])
).rejects.toThrow(ApiError);

try {
await getLogs("test-org", "test-project", ["log-001"]);
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
const apiError = error as ApiError;
expect(apiError.message).toContain("unexpected response format");
expect(apiError.detail).toContain("received string");
expect(apiError.detail).toContain("self-hosted");
}
});
});
Loading