Skip to content
Draft
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
8 changes: 8 additions & 0 deletions apps/api/src/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Resource } from "sst/resource/cloudflare";
import { RequestContext } from "@leuchtturm/api/middleware/request-context";
import { Posthog } from "@leuchtturm/api/posthog";
import { Session } from "@leuchtturm/api/session";
import { isExpectedClientError } from "@leuchtturm/core/errors";

export namespace Observability {
export interface Interface {
Expand Down Expand Up @@ -125,6 +126,13 @@ export namespace Observability {

function captureUnexpectedDefect(defect: unknown, request: RequestContext.Interface) {
return Effect.gen(function* () {
// Expected, handled 4xx client errors (e.g. UnauthorizedError raised by
// the auth middleware for unauthenticated requests) are normal request
// outcomes, not defects. Keep them out of error tracking.
if (isExpectedClientError(defect)) {
return;
}

const currentContext = yield* Effect.context();
const session = Context.getOption(currentContext, Session.Service);

Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";

import {
DatabaseError,
ForbiddenError,
InternalServerError,
NotFoundError,
UnauthorizedError,
ValidationError,
isExpectedClientError,
} from "@leuchtturm/core/errors";

describe("isExpectedClientError", () => {
it("treats handled 4xx client errors as expected", () => {
expect(isExpectedClientError(UnauthorizedError.new())).toBe(true);
expect(isExpectedClientError(ForbiddenError.new())).toBe(true);
expect(isExpectedClientError(NotFoundError.new({ resource: "Document" }))).toBe(true);
expect(isExpectedClientError(new ValidationError({}))).toBe(true);
});

it("does not treat server-side defects as expected", () => {
expect(isExpectedClientError(DatabaseError.new({ operation: "Query" }))).toBe(false);
expect(isExpectedClientError(InternalServerError.new())).toBe(false);
});

it("does not treat arbitrary values as expected", () => {
expect(isExpectedClientError(new Error("boom"))).toBe(false);
expect(isExpectedClientError({ _tag: "UnauthorizedError" })).toBe(false);
expect(isExpectedClientError(undefined)).toBe(false);
expect(isExpectedClientError(null)).toBe(false);
});
});
19 changes: 19 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,22 @@ export const Errors = [
DatabaseError,
InternalServerError,
] as const;

/**
* Expected, typed 4xx client errors. These represent normal request outcomes
* (bad input, missing auth, forbidden, not found) rather than defects, so they
* should never be reported to error tracking.
*/
export const ExpectedClientErrors = [
ValidationError,
UnauthorizedError,
ForbiddenError,
NotFoundError,
] as const;

/**
* Returns `true` when the given value is one of the expected typed client
* errors above. Used to keep handled 4xx failures out of the observability
* capture path.
*/
export const isExpectedClientError = Schema.is(Schema.Union(ExpectedClientErrors));
Loading