-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add telemetry to the chat API route #26
Changes from 2 commits
416fc24
75fa0f4
c2c7116
35d8225
c0ffd06
f9ee5e0
77a56fc
55c1dc6
23d4cd6
ee5a00a
9b34052
377045c
51fe224
82f3975
4d5da51
a0a4588
08b1ee6
5464e5a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,255 @@ | ||
import { | ||
Aila, | ||
AilaAuthenticationError, | ||
AilaThreatDetectionError, | ||
} from "@oakai/aila"; | ||
import type { AilaOptions, AilaPublicChatOptions, Message } from "@oakai/aila"; | ||
import { LooseLessonPlan } from "@oakai/aila/src/protocol/schema"; | ||
import { handleHeliconeError } from "@oakai/aila/src/utils/moderation/moderationErrorHandling"; | ||
import { tracer } from "@oakai/core/src/tracing/serverTracing"; | ||
import { PrismaClientWithAccelerate, prisma as globalPrisma } from "@oakai/db"; | ||
import { | ||
SpanContext, | ||
Span, | ||
SpanStatusCode, | ||
TraceFlags, | ||
} from "@opentelemetry/api"; | ||
import { TraceState } from "@opentelemetry/core"; | ||
import { StreamingTextResponse } from "ai"; | ||
import { NextRequest } from "next/server"; | ||
import invariant from "tiny-invariant"; | ||
|
||
import { Config } from "./config"; | ||
import { streamingJSON } from "./protocol"; | ||
|
||
export const maxDuration = 300; | ||
|
||
const prisma: PrismaClientWithAccelerate = globalPrisma; | ||
|
||
export async function GET() { | ||
return new Response("Chat API is working", { status: 200 }); | ||
} | ||
|
||
async function setupChatHandler(req: NextRequest) { | ||
const json = await req.json(); | ||
const { | ||
id: chatId, | ||
messages, | ||
lessonPlan = {}, | ||
options: chatOptions = {}, | ||
traceContext: serializedTraceContext, | ||
}: { | ||
id: string; | ||
messages: Message[]; | ||
lessonPlan?: LooseLessonPlan; | ||
options?: AilaPublicChatOptions; | ||
traceContext?: { | ||
traceId: string; | ||
spanId: string; | ||
traceFlags: number; | ||
traceState?: string; | ||
}; | ||
} = json; | ||
|
||
let traceContext: SpanContext | undefined; | ||
if (serializedTraceContext) { | ||
traceContext = { | ||
traceId: serializedTraceContext.traceId, | ||
spanId: serializedTraceContext.spanId, | ||
traceFlags: serializedTraceContext.traceFlags as TraceFlags, | ||
traceState: serializedTraceContext.traceState | ||
? new TraceState(serializedTraceContext.traceState) | ||
: undefined, | ||
}; | ||
} | ||
|
||
const options: AilaOptions = { | ||
useRag: chatOptions.useRag ?? true, | ||
temperature: chatOptions.temperature ?? 0.7, | ||
numberOfLessonPlansInRag: chatOptions.numberOfLessonPlansInRag ?? 5, | ||
usePersistence: true, | ||
useModeration: true, | ||
}; | ||
|
||
return { chatId, messages, lessonPlan, options, traceContext }; | ||
} | ||
|
||
function reportErrorTelemetry( | ||
span: Span, | ||
error: Error, | ||
errorType: string, | ||
statusMessage: string, | ||
additionalAttributes: Record<string, unknown> = {}, | ||
) { | ||
span.recordException(error); | ||
span.setStatus({ code: SpanStatusCode.ERROR, message: statusMessage }); | ||
span.setAttributes({ | ||
errorType, | ||
errorStack: error.stack, | ||
...additionalAttributes, | ||
}); | ||
} | ||
|
||
function setTelemetryMetadata( | ||
span: Span, | ||
id: string, | ||
messages: Message[], | ||
lessonPlan: LooseLessonPlan, | ||
options: AilaOptions, | ||
) { | ||
span.setAttributes({ | ||
chatId: id, | ||
messageCount: messages.length, | ||
hasLessonPlan: Object.keys(lessonPlan).length > 0, | ||
useRag: options.useRag, | ||
temperature: options.temperature, | ||
numberOfLessonPlansInRag: options.numberOfLessonPlansInRag, | ||
usePersistence: options.usePersistence, | ||
useModeration: options.useModeration, | ||
}); | ||
} | ||
|
||
function handleConnectionAborted(req: NextRequest) { | ||
const abortController = new AbortController(); | ||
|
||
req.signal.addEventListener("abort", () => { | ||
console.log("Client has disconnected"); | ||
abortController.abort(); | ||
}); | ||
return abortController; | ||
} | ||
|
||
async function handleThreatDetectionError( | ||
span: Span, | ||
e: AilaThreatDetectionError, | ||
userId: string, | ||
id: string, | ||
prisma: PrismaClientWithAccelerate, | ||
) { | ||
const heliconeErrorMessage = await handleHeliconeError(userId, id, e, prisma); | ||
reportErrorTelemetry(span, e, "AilaThreatDetectionError", "Threat detected"); | ||
return streamingJSON(heliconeErrorMessage); | ||
} | ||
|
||
async function handleAilaAuthenticationError( | ||
span: Span, | ||
e: AilaAuthenticationError, | ||
) { | ||
reportErrorTelemetry(span, e, "AilaAuthenticationError", "Unauthorized"); | ||
return new Response("Unauthorized", { status: 401 }); | ||
} | ||
|
||
async function handleGenericError(span: Span, e: Error) { | ||
reportErrorTelemetry(span, e, e.name, e.message); | ||
return streamingJSON({ | ||
type: "error", | ||
message: e.message, | ||
value: `Sorry, an error occurred: ${e.message}`, | ||
}); | ||
} | ||
|
||
async function getUserId( | ||
config: Config, | ||
span: Span, | ||
id: string, | ||
): Promise<string> { | ||
if (config.shouldPerformUserLookup) { | ||
const userLookup = await config.handleUserLookup(span, id); | ||
|
||
if (!userLookup) { | ||
throw new Error("User lookup failed"); | ||
} | ||
|
||
if ("failureResponse" in userLookup) { | ||
if (userLookup.failureResponse) { | ||
throw new Error("User lookup failed: failureResponse received"); | ||
} | ||
} | ||
|
||
if ("userId" in userLookup) { | ||
return userLookup.userId; | ||
} | ||
|
||
throw new Error("User lookup failed: userId not found"); | ||
} | ||
invariant(config.mockUserId, "User ID is required"); | ||
|
||
return config.mockUserId; | ||
} | ||
|
||
async function generateChatStream( | ||
aila: Aila, | ||
abortController: AbortController, | ||
) { | ||
return tracer.startActiveSpan("chat-aila-generate", async (generateSpan) => { | ||
invariant(aila, "Aila instance is required"); | ||
const result = await aila.generate({ abortController }); | ||
generateSpan.end(); | ||
return result; | ||
}); | ||
} | ||
|
||
async function handleChatException( | ||
span: Span, | ||
e: unknown, | ||
userId: string | undefined, | ||
chatId: string, | ||
prisma: PrismaClientWithAccelerate, | ||
): Promise<Response> { | ||
if (e instanceof AilaAuthenticationError) { | ||
return handleAilaAuthenticationError(span, e); | ||
} | ||
|
||
if (e instanceof AilaThreatDetectionError && userId) { | ||
return handleThreatDetectionError(span, e, userId, chatId, prisma); | ||
} | ||
|
||
if (e instanceof Error) { | ||
return handleGenericError(span, e); | ||
} | ||
|
||
throw e; | ||
} | ||
|
||
export async function handleChatPostRequest( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The actual chat post request function now is much simpler and errors are handled in the above methods, which also report telemetry There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function is now so easy to follow at a high level. A lot easier to understand the steps involved |
||
req: NextRequest, | ||
config: Config, | ||
): Promise<Response> { | ||
const { chatId, messages, lessonPlan, options, traceContext } = | ||
await setupChatHandler(req); | ||
|
||
const span = tracer.startSpan("chat-api", { | ||
links: traceContext ? [{ context: traceContext }] : [], | ||
}); | ||
|
||
let userId: string | undefined; | ||
let aila: Aila | undefined; | ||
|
||
try { | ||
setTelemetryMetadata(span, chatId, messages, lessonPlan, options); | ||
|
||
userId = await getUserId(config, span, chatId); | ||
|
||
aila = await config.createAila({ | ||
options, | ||
chat: { | ||
id: chatId, | ||
userId, | ||
messages, | ||
}, | ||
lessonPlan, | ||
}); | ||
|
||
const abortController = handleConnectionAborted(req); | ||
const stream = await generateChatStream(aila, abortController); | ||
console.log("Completed handleChatPostRequest. Returning response"); | ||
return new StreamingTextResponse(stream); | ||
} catch (e) { | ||
return handleChatException(span, e, userId, chatId, prisma); | ||
} finally { | ||
if (aila) { | ||
await aila.ensureShutdown(); | ||
} | ||
span.end(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
import { Aila, AilaInitializationOptions } from "@oakai/aila"; | ||
import { | ||
prisma as globalPrisma, | ||
type PrismaClientWithAccelerate, | ||
} from "@oakai/db"; | ||
import { Span } from "@opentelemetry/api"; | ||
import { nanoid } from "ai"; | ||
|
||
import { handleUserLookup as defaultHandleUserLookup } from "./user"; | ||
import { createWebActionsPlugin } from "./webActionsPlugin"; | ||
|
||
export interface Config { | ||
shouldPerformUserLookup: boolean; | ||
mockUserId?: string; | ||
handleUserLookup: ( | ||
span: Span, | ||
id: string, | ||
) => Promise< | ||
| { | ||
userId: string; | ||
} | ||
| { | ||
failureResponse: Response; | ||
} | ||
>; | ||
prisma: PrismaClientWithAccelerate; | ||
createAila: (options: Partial<AilaInitializationOptions>) => Promise<Aila>; | ||
} | ||
|
||
export const defaultConfig: Config = { | ||
shouldPerformUserLookup: true, | ||
handleUserLookup: defaultHandleUserLookup, | ||
prisma: globalPrisma, | ||
createAila: async (options) => { | ||
const webActionsPlugin = createWebActionsPlugin(globalPrisma); | ||
return new Aila({ | ||
...options, | ||
plugins: [...(options.plugins || []), webActionsPlugin], | ||
prisma: options.prisma ?? globalPrisma, | ||
chat: options.chat || { id: nanoid(), userId: undefined }, | ||
}); | ||
}, | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By breaking this out into a separate file we can write tests without needing to deal with Clerk / Middleware etc.