Skip to content

Commit 2098c83

Browse files
authored
Add OTEL-compatible span ingestion endpoint (#2423)
## Summary Adds a `POST /v1/traces` endpoint to the ingestion server that accepts OTLP/JSON and OTLP/Protobuf payloads, authenticates via `Authorization: Bearer <api-key>`, resolves the target project from an `X-Latitude-Project` header, and inserts the resulting spans into ClickHouse. The endpoint extracts GenAI semantic convention attributes (`gen_ai.system`, `gen_ai.request.model`, token usage, etc.) into dedicated span fields during ingestion. Traces are derived automatically via the existing ClickHouse materialized view — no separate trace write path is needed. API key validation reuses the same SHA-256 hash + Redis cache + Postgres lookup pattern used by the public API, with timing-safe enforcement to prevent enumeration attacks.
1 parent 2ebf4e9 commit 2098c83

File tree

24 files changed

+798
-144
lines changed

24 files changed

+798
-144
lines changed

.husky/pre-commit

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
set -e
33

44
pnpm format
5+
pnpm check:fix
56
pnpm knip

apps/api/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
"@hono/node-server": "catalog:",
2222
"@hono/swagger-ui": "^0.5.0",
2323
"@hono/zod-openapi": "^1.0.0",
24-
"@platform/auth-better": "workspace:*",
2524
"@platform/cache-redis": "workspace:*",
2625
"@platform/db-clickhouse": "workspace:*",
2726
"@platform/db-postgres": "workspace:*",

apps/api/src/clients.ts

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import type { ClickHouseClient } from "@clickhouse/client"
2-
import { createBetterAuth } from "@platform/auth-better"
32
import { createRedisClient, createRedisConnection } from "@platform/cache-redis"
43
import type { RedisClient } from "@platform/cache-redis"
54
import { createClickhouseClient } from "@platform/db-clickhouse"
65
import { type PostgresClient, createPostgresClient } from "@platform/db-postgres"
7-
import { parseEnv, parseEnvOptional } from "@platform/env"
6+
import { parseEnv } from "@platform/env"
87
import { Effect } from "effect"
98

109
let postgresClientInstance: PostgresClient | undefined
1110
let adminPostgresClientInstance: PostgresClient | undefined
1211
let clickhouseInstance: ClickHouseClient | undefined
1312
let redisInstance: RedisClient | undefined
14-
let betterAuthInstance: ReturnType<typeof createBetterAuth> | undefined
1513

1614
export const getPostgresClient = (): PostgresClient => {
1715
if (!postgresClientInstance) {
@@ -54,35 +52,3 @@ export const getRedisClient = (): RedisClient => {
5452
}
5553
return redisInstance
5654
}
57-
58-
/**
59-
* Get or create the Better Auth instance.
60-
*
61-
* This is a singleton to ensure the same auth instance is used
62-
* across routes and middleware.
63-
*/
64-
export const getBetterAuth = () => {
65-
if (!betterAuthInstance) {
66-
const { db } = getPostgresClient()
67-
const baseUrl = Effect.runSync(parseEnv("LAT_BETTER_AUTH_URL", "string"))
68-
const betterAuthSecret = Effect.runSync(parseEnv("LAT_BETTER_AUTH_SECRET", "string"))
69-
const webUrl = Effect.runSync(parseEnv("LAT_WEB_URL", "string", "http://localhost:3000"))
70-
71-
// Parse trusted origins from comma-separated env var or fallback to webUrl
72-
const trustedOriginsEnv = Effect.runSync(parseEnvOptional("LAT_TRUSTED_ORIGINS", "string"))
73-
const trustedOrigins = trustedOriginsEnv
74-
? trustedOriginsEnv
75-
.split(",")
76-
.map((o) => o.trim())
77-
.filter(Boolean)
78-
: [webUrl]
79-
80-
betterAuthInstance = createBetterAuth({
81-
db,
82-
secret: betterAuthSecret,
83-
baseUrl,
84-
trustedOrigins,
85-
})
86-
}
87-
return betterAuthInstance
88-
}

apps/api/src/middleware/auth.ts

Lines changed: 11 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import { OrganizationId, UnauthorizedError, UserId } from "@domain/shared"
2-
import {
3-
type PostgresDb,
4-
createApiKeyPostgresRepository,
5-
createMembershipPostgresRepository,
6-
} from "@platform/db-postgres"
2+
import { type PostgresDb, createApiKeyPostgresRepository } from "@platform/db-postgres"
73
import { hashToken } from "@repo/utils"
84
import { Effect, Option } from "effect"
95
import type { Context, MiddlewareHandler, Next } from "hono"
10-
import { getAdminPostgresClient, getBetterAuth } from "../clients.ts"
6+
import { getAdminPostgresClient } from "../clients.ts"
117
import type { AuthContext } from "../types.ts"
128
import { createTouchBuffer } from "./touch-buffer.ts"
139

@@ -160,27 +156,6 @@ const enforceMinimumTime = (startTime: number, minMs: number): Effect.Effect<voi
160156
return Effect.void
161157
}
162158

163-
/**
164-
* Validate that a user is a member of the specified organization.
165-
* Returns true if membership is valid, false otherwise.
166-
*/
167-
const validateOrganizationMembership = (
168-
c: Context,
169-
userId: string,
170-
organizationId: string,
171-
): Effect.Effect<boolean, never> => {
172-
const membershipRepository = createMembershipPostgresRepository(c.get("db"))
173-
return membershipRepository.isMember(OrganizationId(organizationId), userId).pipe(Effect.orDie)
174-
}
175-
176-
/**
177-
* Extract API key token from request headers.
178-
*/
179-
const extractApiKeyToken = (c: Context): string | undefined => {
180-
const apiKeyHeader = c.req.header("X-API-Key")
181-
return apiKeyHeader || undefined
182-
}
183-
184159
const extractBearerToken = (c: Context): string | undefined => {
185160
const authHeader = c.req.header("Authorization")
186161
if (!authHeader?.startsWith("Bearer ")) {
@@ -216,77 +191,31 @@ const authenticateWithApiKey = (
216191
}
217192

218193
/**
219-
* Authenticate via JWT bearer token.
220-
*/
221-
const authenticateWithJwt = (c: Context, token: string): Effect.Effect<AuthContext | null, never> => {
222-
return Effect.gen(function* () {
223-
const auth = getBetterAuth()
224-
const headers = new Headers(c.req.raw.headers)
225-
headers.set("authorization", `Bearer ${token}`)
226-
227-
const session = yield* Effect.tryPromise({
228-
try: () => auth.api.getSession({ headers }),
229-
catch: () => null,
230-
}).pipe(Effect.orDie)
231-
232-
if (!session?.user) {
233-
return null
234-
}
235-
236-
const orgId = c.req.param("organizationId")
237-
if (!orgId) {
238-
return null
239-
}
240-
241-
const isMember = yield* validateOrganizationMembership(c, session.user.id, orgId)
242-
if (!isMember) {
243-
return null
244-
}
245-
246-
const authContext: AuthContext = {
247-
userId: UserId(session.user.id),
248-
organizationId: OrganizationId(orgId),
249-
method: "jwt",
250-
}
251-
252-
return authContext
253-
}).pipe(Effect.orDie)
254-
}
255-
256-
/**
257-
* Main authentication effect that tries all authentication methods.
194+
* Authenticate via API key from the Authorization: Bearer header.
258195
*/
259196
const authenticate = (c: Context, options?: AuthMiddlewareOptions): Effect.Effect<AuthContext, UnauthorizedError> => {
260197
return Effect.gen(function* () {
261-
const apiKeyToken = extractApiKeyToken(c)
262198
const bearerToken = extractBearerToken(c)
263199

264-
let authContext: AuthContext | null = null
265-
266-
if (apiKeyToken) {
267-
authContext = yield* authenticateWithApiKey(c, apiKeyToken, options)
268-
} else if (bearerToken) {
269-
authContext = yield* authenticateWithJwt(c, bearerToken)
270-
}
271-
272-
if (!authContext) {
200+
if (!bearerToken) {
273201
return yield* new UnauthorizedError({
274202
message: "Authentication required",
275203
})
276204
}
277205

278-
return authContext
206+
const authContext = yield* authenticateWithApiKey(c, bearerToken, options)
207+
if (authContext) return authContext
208+
209+
return yield* new UnauthorizedError({
210+
message: "Invalid API key",
211+
})
279212
})
280213
}
281214

282215
/**
283216
* Create authentication middleware.
284217
*
285-
* This middleware validates requests using one of two methods:
286-
* 1. JWT Bearer token (Better Auth)
287-
* 2. API Key
288-
*
289-
* The middleware sets auth context on the Hono context for downstream handlers.
218+
* Validates API keys sent via the Authorization: Bearer header.
290219
* Public routes should be excluded from this middleware.
291220
*/
292221
interface AuthMiddlewareOptions {

apps/api/src/middleware/cors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const registerCorsMiddleware = (app: Hono, options: RegisterCorsMiddlewar
4242
return null
4343
},
4444
allowMethods: ["GET", "POST", "PUT", "DELETE"],
45-
allowHeaders: ["Content-Type", "Authorization", "X-API-Key"],
45+
allowHeaders: ["Content-Type", "Authorization"],
4646
credentials: true,
4747
maxAge: 86400,
4848
exposeHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],

apps/api/src/server.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,8 @@ registerRoutes({
4242

4343
// Register security scheme via the OpenAPI registry
4444
app.openAPIRegistry.registerComponent("securitySchemes", "ApiKeyAuth", {
45-
type: "apiKey",
46-
in: "header",
47-
name: "X-API-Key",
45+
type: "http",
46+
scheme: "bearer",
4847
description: "Organization-scoped API key",
4948
})
5049

@@ -54,7 +53,7 @@ app.doc("/openapi.json", {
5453
info: {
5554
title: "Latitude API",
5655
version: "1.0.0",
57-
description: "The Latitude public API. Authenticate using an API key via the `X-API-Key` header.",
56+
description: "The Latitude public API. Authenticate using an API key via the `Authorization: Bearer` header.",
5857
},
5958
servers: [{ url: `http://localhost:${port}`, description: "Local development" }],
6059
})

apps/api/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface AuthContext {
2323
/** The organization ID for this request (from URL param or API key) */
2424
readonly organizationId: OrganizationId
2525
/** The authentication method that was used */
26-
readonly method: "jwt" | "api-key"
26+
readonly method: "api-key"
2727
}
2828

2929
/**

apps/ingest/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,19 @@
1414
},
1515
"dependencies": {
1616
"@clickhouse/client": "catalog:",
17+
"@domain/shared": "workspace:*",
18+
"@domain/spans": "workspace:*",
1719
"@hono/node-server": "catalog:",
20+
"@platform/cache-redis": "workspace:*",
1821
"@platform/db-clickhouse": "workspace:*",
1922
"@platform/db-postgres": "workspace:*",
2023
"@platform/env": "workspace:*",
2124
"@repo/observability": "workspace:*",
25+
"@repo/utils": "workspace:*",
2226
"dotenv": "catalog:",
2327
"effect": "catalog:",
2428
"hono": "catalog:",
29+
"protobufjs": "catalog:",
2530
"tsx": "catalog:"
2631
}
2732
}

apps/ingest/src/clients.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,33 @@
11
import type { ClickHouseClient } from "@clickhouse/client"
2+
import { createRedisClient, createRedisConnection } from "@platform/cache-redis"
3+
import type { RedisClient } from "@platform/cache-redis"
24
import { createClickhouseClient } from "@platform/db-clickhouse"
3-
import { createPostgresPool } from "@platform/db-postgres"
5+
import { type PostgresClient, createPostgresClient } from "@platform/db-postgres"
6+
import { parseEnv } from "@platform/env"
7+
import { Effect } from "effect"
48

5-
let postgresPoolInstance: ReturnType<typeof createPostgresPool> | undefined
9+
let postgresClientInstance: PostgresClient | undefined
10+
let adminPostgresClientInstance: PostgresClient | undefined
611
let clickhouseInstance: ClickHouseClient | undefined
12+
let redisInstance: RedisClient | undefined
713

8-
export const getPostgresPool = (): ReturnType<typeof createPostgresPool> => {
9-
if (!postgresPoolInstance) {
10-
postgresPoolInstance = createPostgresPool()
14+
export const getPostgresClient = (): PostgresClient => {
15+
if (!postgresClientInstance) {
16+
postgresClientInstance = createPostgresClient()
1117
}
12-
return postgresPoolInstance
18+
return postgresClientInstance
19+
}
20+
21+
/**
22+
* Admin Postgres connection that bypasses RLS.
23+
* Used for cross-org lookups: API key auth and project resolution.
24+
*/
25+
export const getAdminPostgresClient = (): PostgresClient => {
26+
if (!adminPostgresClientInstance) {
27+
const adminUrl = Effect.runSync(parseEnv("LAT_ADMIN_DATABASE_URL", "string"))
28+
adminPostgresClientInstance = createPostgresClient({ databaseUrl: adminUrl })
29+
}
30+
return adminPostgresClientInstance
1331
}
1432

1533
export const getClickhouseClient = (): ClickHouseClient => {
@@ -18,3 +36,11 @@ export const getClickhouseClient = (): ClickHouseClient => {
1836
}
1937
return clickhouseInstance
2038
}
39+
40+
export const getRedisClient = (): RedisClient => {
41+
if (!redisInstance) {
42+
const redisConn = createRedisConnection()
43+
redisInstance = createRedisClient(redisConn)
44+
}
45+
return redisInstance
46+
}

0 commit comments

Comments
 (0)