Skip to content
Merged
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
1 change: 1 addition & 0 deletions cloud/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export * from "@/api/environments.schemas";
export * from "@/api/api-keys.schemas";
export * from "@/api/functions.schemas";
export * from "@/api/annotations.schemas";
export * from "@/api/search.schemas";

export class MirascopeCloudApi extends HttpApi.make("MirascopeCloudApi")
.add(HealthApi)
Expand Down
61 changes: 55 additions & 6 deletions cloud/api/handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { Effect } from "effect";
import { Effect, Layer } from "effect";
import { describe, it, expect } from "@/tests/api";
import { handleRequest } from "@/api/handler";
import type { PublicUser } from "@/db/schema";
import { HandlerError } from "@/errors";
import { ClickHouse } from "@/clickhouse/client";
import { ClickHouseSearch } from "@/clickhouse/search";
import { SettingsService, getSettings } from "@/settings";
import { CLICKHOUSE_CONNECTION_FILE } from "@/tests/global-setup";
import fs from "fs";

const mockUser: PublicUser = {
id: "test-user-id",
Expand All @@ -11,9 +16,44 @@ const mockUser: PublicUser = {
deletedAt: null,
};

type ClickHouseConnectionFile = {
url: string;
user: string;
password: string;
database: string;
nativePort: number;
};

function getTestClickHouseConfig(): ClickHouseConnectionFile {
try {
const raw = fs.readFileSync(CLICKHOUSE_CONNECTION_FILE, "utf-8");
return JSON.parse(raw) as ClickHouseConnectionFile;
} catch {
throw new Error(
"TEST_CLICKHOUSE_URL not set. Ensure global-setup.ts ran successfully.",
);
}
}

const clickhouseConfig = getTestClickHouseConfig();
const settings = getSettings();
const settingsLayer = Layer.succeed(SettingsService, {
...settings,
env: "test",
CLICKHOUSE_URL: clickhouseConfig.url,
CLICKHOUSE_USER: clickhouseConfig.user,
CLICKHOUSE_PASSWORD: clickhouseConfig.password,
CLICKHOUSE_DATABASE: clickhouseConfig.database,
});
const clickHouseSearchLayer = ClickHouseSearch.Default.pipe(
Layer.provide(ClickHouse.Default),
Layer.provide(settingsLayer),
);

describe("handleRequest", () => {
it.effect("should return 404 for non-existing routes", () =>
Effect.gen(function* () {
const clickHouseSearch = yield* ClickHouseSearch;
const req = new Request(
"http://localhost/api/v0/this-route-does-not-exist",
{ method: "GET" },
Expand All @@ -23,35 +63,39 @@ describe("handleRequest", () => {
user: mockUser,
environment: "test",
prefix: "/api/v0",
clickHouseSearch,
});

expect(response.status).toBe(404);
expect(matched).toBe(false);
}),
}).pipe(Effect.provide(clickHouseSearchLayer)),
);

it.effect(
"should return 404 when pathname exactly matches prefix (no route)",
() =>
Effect.gen(function* () {
const clickHouseSearch = yield* ClickHouseSearch;
const req = new Request("http://localhost/api/v0", { method: "GET" });

const { matched, response } = yield* handleRequest(req, {
user: mockUser,
environment: "test",
prefix: "/api/v0",
clickHouseSearch,
});

// The path becomes "/" after stripping prefix, which doesn't match any route
expect(response.status).toBe(404);
expect(matched).toBe(false);
}),
}).pipe(Effect.provide(clickHouseSearchLayer)),
);

it.effect(
"should return error for a request that triggers an exception",
() =>
Effect.gen(function* () {
const clickHouseSearch = yield* ClickHouseSearch;
const faultyRequest = new Proxy(
{},
{
Expand All @@ -64,17 +108,19 @@ describe("handleRequest", () => {
const error = yield* handleRequest(faultyRequest, {
user: mockUser,
environment: "test",
clickHouseSearch,
}).pipe(Effect.flip);

expect(error).toBeInstanceOf(HandlerError);
expect(error.message).toContain(
"[Effect API] Error handling request: boom",
);
}),
}).pipe(Effect.provide(clickHouseSearchLayer)),
);

it.effect("should handle POST requests with body", () =>
Effect.gen(function* () {
const clickHouseSearch = yield* ClickHouseSearch;
// POST request with body to trigger duplex: "half"
const req = new Request(
"http://localhost/api/v0/organizations/00000000-0000-0000-0000-000000000099/projects",
Expand All @@ -89,15 +135,17 @@ describe("handleRequest", () => {
user: mockUser,
environment: "test",
prefix: "/api/v0",
clickHouseSearch,
});

expect(matched).toBe(true);
expect(response.status).toBeGreaterThanOrEqual(400);
}),
}).pipe(Effect.provide(clickHouseSearchLayer)),
);

it.effect("should transform _tag in JSON error responses", () =>
Effect.gen(function* () {
const clickHouseSearch = yield* ClickHouseSearch;
// Trigger a NotFoundError by trying to get a non-existent organization
const req = new Request(
"http://localhost/api/v0/organizations/00000000-0000-0000-0000-000000000099",
Expand All @@ -110,6 +158,7 @@ describe("handleRequest", () => {
user: mockUser,
environment: "test",
prefix: "/api/v0",
clickHouseSearch,
});

const body = yield* Effect.promise(() => response.text());
Expand All @@ -119,6 +168,6 @@ describe("handleRequest", () => {
// Ensure _tag is transformed to tag in error responses
expect(body).toContain('"tag"');
expect(body).not.toContain('"_tag"');
}),
}).pipe(Effect.provide(clickHouseSearchLayer)),
);
});
12 changes: 10 additions & 2 deletions cloud/api/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { HttpApiBuilder, HttpServer } from "@effect/platform";
import { Context, Effect, Layer } from "effect";
import { ApiLive } from "@/api/router";
import { HandlerError } from "@/errors";
import { SettingsService } from "@/settings";
import { SettingsService, getSettings } from "@/settings";
import { Database } from "@/db";
import { Payments } from "@/payments";
import { ClickHouseSearch } from "@/clickhouse/search";
import { AuthenticatedUser, Authentication } from "@/auth";
import type { PublicUser, ApiKeyInfo } from "@/db/schema";

Expand All @@ -13,26 +14,32 @@ export type HandleRequestOptions = {
user: PublicUser;
apiKeyInfo?: ApiKeyInfo;
environment: string;
clickHouseSearch: Context.Tag.Service<ClickHouseSearch>;
};

type WebHandlerOptions = {
db: Context.Tag.Service<Database>;
payments: Context.Tag.Service<Payments>;
clickHouseSearch: Context.Tag.Service<ClickHouseSearch>;
user: PublicUser;
apiKeyInfo?: ApiKeyInfo;
environment: string;
};

function createWebHandler(options: WebHandlerOptions) {
const services = Layer.mergeAll(
Layer.succeed(SettingsService, { env: options.environment }),
Layer.succeed(SettingsService, {
...getSettings(),
env: options.environment,
}),
Layer.succeed(AuthenticatedUser, options.user),
Layer.succeed(Authentication, {
user: options.user,
apiKeyInfo: options.apiKeyInfo,
}),
Layer.succeed(Database, options.db),
Layer.succeed(Payments, options.payments),
Layer.succeed(ClickHouseSearch, options.clickHouseSearch),
);

const ApiWithDependencies = Layer.merge(
Expand Down Expand Up @@ -71,6 +78,7 @@ export const handleRequest = (
user: options.user,
apiKeyInfo: options.apiKeyInfo,
environment: options.environment,
clickHouseSearch: options.clickHouseSearch,
});

const result = yield* Effect.tryPromise({
Expand Down
16 changes: 15 additions & 1 deletion cloud/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ import {
updateAnnotationHandler,
deleteAnnotationHandler,
} from "@/api/annotations.handlers";
import {
searchHandler,
getTraceDetailHandler,
getAnalyticsSummaryHandler,
} from "@/api/search.handlers";
import { MirascopeCloudApi } from "@/api/api";

export { MirascopeCloudApi };
Expand All @@ -60,7 +65,16 @@ const TracesHandlersLive = HttpApiBuilder.group(
MirascopeCloudApi,
"traces",
(handlers) =>
handlers.handle("create", ({ payload }) => createTraceHandler(payload)),
handlers
// TODO: Add missing router handlers e.g. list, listByFunctionHash, etc.
.handle("create", ({ payload }) => createTraceHandler(payload))
.handle("search", ({ payload }) => searchHandler(payload))
.handle("getTraceDetail", ({ path }) =>
getTraceDetailHandler(path.traceId),
)
.handle("getAnalyticsSummary", ({ urlParams }) =>
getAnalyticsSummaryHandler(urlParams),
),
);

const DocsHandlersLive = HttpApiBuilder.group(
Expand Down
129 changes: 129 additions & 0 deletions cloud/api/search.handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* @fileoverview Search API handlers for ClickHouse-powered search.
*
* Provides handlers for searching spans, retrieving trace details,
* and getting analytics summaries from ClickHouse.
*
* ## Authentication
*
* All endpoints require API key authentication. The environment ID
* is extracted from the API key scope.
*
* @example
* ```ts
* // Handler usage (internal)
* const result = yield* searchHandler({ startTime: "...", endTime: "..." });
* ```
*/

import { Effect } from "effect";
import { Authentication } from "@/auth";
import {
ClickHouseSearch,
type SpanSearchInput,
type TraceDetailInput,
type AnalyticsSummaryInput,
type AttributeFilter,
} from "@/clickhouse/search";
import type {
SearchRequest,
AnalyticsSummaryRequest,
} from "@/api/search.schemas";

export * from "@/api/search.schemas";

// =============================================================================
// Handlers
// =============================================================================

/**
* Search spans with filters and pagination.
*
* Requires API key authentication. Uses the environment ID from the API key scope.
*/
export const searchHandler = (payload: SearchRequest) =>
Effect.gen(function* () {
const { apiKeyInfo } = yield* Authentication.ApiKey;
const searchService = yield* ClickHouseSearch;

// Convert readonly arrays to mutable arrays
const model =
payload.model && payload.model.length > 0
? [...payload.model]
: undefined;
const provider =
payload.provider && payload.provider.length > 0
? [...payload.provider]
: undefined;
const attributeFilters: AttributeFilter[] | undefined =
payload.attributeFilters
? payload.attributeFilters.map((f) => ({
key: f.key,
operator: f.operator,
value: f.value,
}))
: undefined;

const input: SpanSearchInput = {
environmentId: apiKeyInfo.environmentId,
startTime: new Date(payload.startTime),
endTime: new Date(payload.endTime),
query: payload.query,
traceId: payload.traceId,
spanId: payload.spanId,
model,
provider,
functionId: payload.functionId,
functionName: payload.functionName,
hasError: payload.hasError,
minTokens: payload.minTokens,
maxTokens: payload.maxTokens,
minDuration: payload.minDuration,
maxDuration: payload.maxDuration,
attributeFilters,
limit: payload.limit,
offset: payload.offset,
sortBy: payload.sortBy,
sortOrder: payload.sortOrder,
};

return yield* searchService.search(input);
});

/**
* Get full trace detail with all spans.
*
* Requires API key authentication. Uses the environment ID from the API key scope.
*/
export const getTraceDetailHandler = (traceId: string) =>
Effect.gen(function* () {
const { apiKeyInfo } = yield* Authentication.ApiKey;
const searchService = yield* ClickHouseSearch;

const input: TraceDetailInput = {
environmentId: apiKeyInfo.environmentId,
traceId,
};

return yield* searchService.getTraceDetail(input);
});

/**
* Get analytics summary for a time range.
*
* Requires API key authentication. Uses the environment ID from the API key scope.
*/
export const getAnalyticsSummaryHandler = (params: AnalyticsSummaryRequest) =>
Effect.gen(function* () {
const { apiKeyInfo } = yield* Authentication.ApiKey;
const searchService = yield* ClickHouseSearch;

const input: AnalyticsSummaryInput = {
environmentId: apiKeyInfo.environmentId,
startTime: new Date(params.startTime),
endTime: new Date(params.endTime),
functionId: params.functionId,
};

return yield* searchService.getAnalyticsSummary(input);
});
Loading
Loading