@nextsparkjs/plugin-langchain 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +41 -0
- package/api/observability/metrics/route.ts +110 -0
- package/api/observability/traces/[traceId]/route.ts +398 -0
- package/api/observability/traces/route.ts +205 -0
- package/api/sessions/route.ts +332 -0
- package/components/observability/CollapsibleJson.tsx +71 -0
- package/components/observability/CompactTimeline.tsx +75 -0
- package/components/observability/ConversationFlow.tsx +271 -0
- package/components/observability/DisabledMessage.tsx +21 -0
- package/components/observability/FiltersPanel.tsx +82 -0
- package/components/observability/ObservabilityDashboard.tsx +230 -0
- package/components/observability/SpansList.tsx +210 -0
- package/components/observability/TraceDetail.tsx +335 -0
- package/components/observability/TraceStatusBadge.tsx +39 -0
- package/components/observability/TracesTable.tsx +97 -0
- package/components/observability/index.ts +7 -0
- package/docs/01-getting-started/01-overview.md +196 -0
- package/docs/01-getting-started/02-installation.md +368 -0
- package/docs/01-getting-started/03-configuration.md +794 -0
- package/docs/02-core-concepts/01-architecture.md +566 -0
- package/docs/02-core-concepts/02-agents.md +597 -0
- package/docs/02-core-concepts/03-tools.md +689 -0
- package/docs/03-orchestration/01-graph-orchestrator.md +809 -0
- package/docs/03-orchestration/02-legacy-react.md +650 -0
- package/docs/04-advanced/01-observability.md +645 -0
- package/docs/04-advanced/02-token-tracking.md +469 -0
- package/docs/04-advanced/03-streaming.md +476 -0
- package/docs/04-advanced/04-guardrails.md +597 -0
- package/docs/05-reference/01-api-reference.md +1403 -0
- package/docs/05-reference/02-customization.md +646 -0
- package/docs/05-reference/03-examples.md +881 -0
- package/docs/index.md +85 -0
- package/hooks/observability/useMetrics.ts +31 -0
- package/hooks/observability/useTraceDetail.ts +48 -0
- package/hooks/observability/useTraces.ts +59 -0
- package/lib/agent-factory.ts +354 -0
- package/lib/agent-helpers.ts +201 -0
- package/lib/db-memory-store.ts +417 -0
- package/lib/graph/index.ts +58 -0
- package/lib/graph/nodes/combiner.ts +399 -0
- package/lib/graph/nodes/router.ts +440 -0
- package/lib/graph/orchestrator-graph.ts +386 -0
- package/lib/graph/prompts/combiner.md +131 -0
- package/lib/graph/prompts/router.md +193 -0
- package/lib/graph/types.ts +365 -0
- package/lib/guardrails.ts +230 -0
- package/lib/index.ts +44 -0
- package/lib/logger.ts +70 -0
- package/lib/memory-store.ts +168 -0
- package/lib/message-serializer.ts +110 -0
- package/lib/prompt-renderer.ts +94 -0
- package/lib/providers.ts +226 -0
- package/lib/streaming.ts +232 -0
- package/lib/token-tracker.ts +298 -0
- package/lib/tools-builder.ts +192 -0
- package/lib/tracer-callbacks.ts +342 -0
- package/lib/tracer.ts +350 -0
- package/migrations/001_langchain_memory.sql +83 -0
- package/migrations/002_token_usage.sql +127 -0
- package/migrations/003_observability.sql +257 -0
- package/package.json +28 -0
- package/plugin.config.ts +170 -0
- package/presets/lib/langchain.config.ts.preset +142 -0
- package/presets/templates/sector7/ai-observability/[traceId]/page.tsx +91 -0
- package/presets/templates/sector7/ai-observability/page.tsx +54 -0
- package/types/langchain.types.ts +274 -0
- package/types/observability.types.ts +270 -0
package/lib/tracer.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LangChain Tracer Service
|
|
3
|
+
*
|
|
4
|
+
* Core tracing service for LangChain agent observability.
|
|
5
|
+
* Tracks traces (agent invocations) and spans (individual operations).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from 'crypto'
|
|
9
|
+
import { queryWithRLS, mutateWithRLS } from '@nextsparkjs/core/lib/db'
|
|
10
|
+
import type {
|
|
11
|
+
ObservabilityConfig,
|
|
12
|
+
TraceContext,
|
|
13
|
+
SpanContext,
|
|
14
|
+
StartTraceOptions,
|
|
15
|
+
EndTraceOptions,
|
|
16
|
+
StartSpanOptions,
|
|
17
|
+
EndSpanOptions,
|
|
18
|
+
ContentType,
|
|
19
|
+
} from '../types/observability.types'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tracer service singleton
|
|
23
|
+
*/
|
|
24
|
+
class Tracer {
|
|
25
|
+
private config: ObservabilityConfig | null = null
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Initialize tracer with configuration
|
|
29
|
+
* MUST be called by theme before using tracer
|
|
30
|
+
*/
|
|
31
|
+
init(config: ObservabilityConfig): void {
|
|
32
|
+
this.config = config
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if tracer has been initialized
|
|
37
|
+
*/
|
|
38
|
+
isInitialized(): boolean {
|
|
39
|
+
return this.config !== null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if tracing should occur based on configuration
|
|
44
|
+
* Considers enabled flag and sampling rate
|
|
45
|
+
*/
|
|
46
|
+
shouldTrace(isError = false): boolean {
|
|
47
|
+
if (!this.config) {
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!this.config.enabled) {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Always trace errors if configured
|
|
56
|
+
if (isError && this.config.sampling.alwaysTraceErrors) {
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Apply sampling rate
|
|
61
|
+
return Math.random() < this.config.sampling.rate
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Start a new trace
|
|
66
|
+
*/
|
|
67
|
+
async startTrace(
|
|
68
|
+
context: { userId: string; teamId: string },
|
|
69
|
+
agentName: string,
|
|
70
|
+
input: string,
|
|
71
|
+
options?: StartTraceOptions
|
|
72
|
+
): Promise<TraceContext | null> {
|
|
73
|
+
if (!this.shouldTrace()) {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const traceId = randomUUID()
|
|
78
|
+
const processedInput = this.processContent(input, 'input')
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await mutateWithRLS(
|
|
82
|
+
`INSERT INTO public."langchain_traces" (
|
|
83
|
+
"traceId",
|
|
84
|
+
"userId",
|
|
85
|
+
"teamId",
|
|
86
|
+
"sessionId",
|
|
87
|
+
"agentName",
|
|
88
|
+
"agentType",
|
|
89
|
+
"parentId",
|
|
90
|
+
input,
|
|
91
|
+
status,
|
|
92
|
+
metadata,
|
|
93
|
+
tags,
|
|
94
|
+
"startedAt"
|
|
95
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now())`,
|
|
96
|
+
[
|
|
97
|
+
traceId,
|
|
98
|
+
context.userId,
|
|
99
|
+
context.teamId,
|
|
100
|
+
options?.sessionId || null,
|
|
101
|
+
agentName,
|
|
102
|
+
options?.agentType || null,
|
|
103
|
+
options?.parentId || null,
|
|
104
|
+
processedInput,
|
|
105
|
+
'running',
|
|
106
|
+
JSON.stringify(options?.metadata || {}),
|
|
107
|
+
options?.tags || [],
|
|
108
|
+
],
|
|
109
|
+
context.userId
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
traceId,
|
|
114
|
+
userId: context.userId,
|
|
115
|
+
teamId: context.teamId,
|
|
116
|
+
sessionId: options?.sessionId,
|
|
117
|
+
agentName,
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error('[Tracer] Failed to start trace:', error)
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* End a trace
|
|
127
|
+
*/
|
|
128
|
+
async endTrace(
|
|
129
|
+
context: { userId: string; teamId: string },
|
|
130
|
+
traceId: string,
|
|
131
|
+
options: EndTraceOptions
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
try {
|
|
134
|
+
const status = options.error ? 'error' : 'success'
|
|
135
|
+
const processedOutput = options.output
|
|
136
|
+
? this.processContent(options.output, 'output')
|
|
137
|
+
: null
|
|
138
|
+
|
|
139
|
+
// Extract error details
|
|
140
|
+
let errorMessage: string | null = null
|
|
141
|
+
let errorType: string | null = null
|
|
142
|
+
let errorStack: string | null = null
|
|
143
|
+
|
|
144
|
+
if (options.error) {
|
|
145
|
+
if (options.error instanceof Error) {
|
|
146
|
+
errorMessage = options.error.message
|
|
147
|
+
errorType = options.error.name
|
|
148
|
+
errorStack = options.error.stack || null
|
|
149
|
+
} else {
|
|
150
|
+
errorMessage = String(options.error)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await mutateWithRLS(
|
|
155
|
+
`UPDATE public."langchain_traces"
|
|
156
|
+
SET
|
|
157
|
+
output = $1,
|
|
158
|
+
status = $2,
|
|
159
|
+
error = $3,
|
|
160
|
+
"errorType" = $4,
|
|
161
|
+
"errorStack" = $5,
|
|
162
|
+
"endedAt" = now(),
|
|
163
|
+
"durationMs" = EXTRACT(EPOCH FROM (now() - "startedAt")) * 1000,
|
|
164
|
+
"inputTokens" = $6,
|
|
165
|
+
"outputTokens" = $7,
|
|
166
|
+
"totalTokens" = $8,
|
|
167
|
+
"totalCost" = $9,
|
|
168
|
+
"llmCalls" = $10,
|
|
169
|
+
"toolCalls" = $11,
|
|
170
|
+
metadata = COALESCE(metadata, '{}'::jsonb) || $12::jsonb
|
|
171
|
+
WHERE "traceId" = $13`,
|
|
172
|
+
[
|
|
173
|
+
processedOutput,
|
|
174
|
+
status,
|
|
175
|
+
errorMessage,
|
|
176
|
+
errorType,
|
|
177
|
+
errorStack,
|
|
178
|
+
options.tokens?.input || 0,
|
|
179
|
+
options.tokens?.output || 0,
|
|
180
|
+
options.tokens?.total || 0,
|
|
181
|
+
options.cost || 0,
|
|
182
|
+
options.llmCalls || 0,
|
|
183
|
+
options.toolCalls || 0,
|
|
184
|
+
JSON.stringify(options.metadata || {}),
|
|
185
|
+
traceId,
|
|
186
|
+
],
|
|
187
|
+
context.userId
|
|
188
|
+
)
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error('[Tracer] Failed to end trace:', error)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Start a span within a trace
|
|
196
|
+
*/
|
|
197
|
+
async startSpan(
|
|
198
|
+
context: { userId: string; teamId: string },
|
|
199
|
+
traceId: string,
|
|
200
|
+
options: StartSpanOptions
|
|
201
|
+
): Promise<SpanContext | null> {
|
|
202
|
+
if (!this.config?.enabled) {
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const spanId = randomUUID()
|
|
207
|
+
const depth = options.depth || 0
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await mutateWithRLS(
|
|
211
|
+
`INSERT INTO public."langchain_spans" (
|
|
212
|
+
"spanId",
|
|
213
|
+
"traceId",
|
|
214
|
+
"parentSpanId",
|
|
215
|
+
name,
|
|
216
|
+
type,
|
|
217
|
+
provider,
|
|
218
|
+
model,
|
|
219
|
+
"toolName",
|
|
220
|
+
input,
|
|
221
|
+
status,
|
|
222
|
+
depth,
|
|
223
|
+
"startedAt"
|
|
224
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now())`,
|
|
225
|
+
[
|
|
226
|
+
spanId,
|
|
227
|
+
traceId,
|
|
228
|
+
options.parentSpanId || null,
|
|
229
|
+
options.name,
|
|
230
|
+
options.type,
|
|
231
|
+
options.provider || null,
|
|
232
|
+
options.model || null,
|
|
233
|
+
options.toolName || null,
|
|
234
|
+
options.input ? JSON.stringify(options.input) : null,
|
|
235
|
+
'running',
|
|
236
|
+
depth,
|
|
237
|
+
],
|
|
238
|
+
context.userId
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
spanId,
|
|
243
|
+
traceId,
|
|
244
|
+
parentSpanId: options.parentSpanId,
|
|
245
|
+
name: options.name,
|
|
246
|
+
type: options.type,
|
|
247
|
+
depth,
|
|
248
|
+
startedAt: new Date(),
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
console.error('[Tracer] Failed to start span:', error)
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* End a span
|
|
258
|
+
*/
|
|
259
|
+
async endSpan(
|
|
260
|
+
context: { userId: string; teamId: string },
|
|
261
|
+
traceId: string,
|
|
262
|
+
spanId: string,
|
|
263
|
+
options: EndSpanOptions
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
try {
|
|
266
|
+
const status = options.error ? 'error' : 'success'
|
|
267
|
+
const errorMessage = options.error
|
|
268
|
+
? options.error instanceof Error
|
|
269
|
+
? options.error.message
|
|
270
|
+
: String(options.error)
|
|
271
|
+
: null
|
|
272
|
+
|
|
273
|
+
await mutateWithRLS(
|
|
274
|
+
`UPDATE public."langchain_spans"
|
|
275
|
+
SET
|
|
276
|
+
output = $1,
|
|
277
|
+
"toolInput" = $2,
|
|
278
|
+
"toolOutput" = $3,
|
|
279
|
+
status = $4,
|
|
280
|
+
error = $5,
|
|
281
|
+
"inputTokens" = $6,
|
|
282
|
+
"outputTokens" = $7,
|
|
283
|
+
"endedAt" = now(),
|
|
284
|
+
"durationMs" = EXTRACT(EPOCH FROM (now() - "startedAt")) * 1000
|
|
285
|
+
WHERE "traceId" = $8 AND "spanId" = $9`,
|
|
286
|
+
[
|
|
287
|
+
options.output ? JSON.stringify(options.output) : null,
|
|
288
|
+
options.toolInput ? JSON.stringify(options.toolInput) : null,
|
|
289
|
+
options.toolOutput ? JSON.stringify(options.toolOutput) : null,
|
|
290
|
+
status,
|
|
291
|
+
errorMessage,
|
|
292
|
+
options.tokens?.input || null,
|
|
293
|
+
options.tokens?.output || null,
|
|
294
|
+
traceId,
|
|
295
|
+
spanId,
|
|
296
|
+
],
|
|
297
|
+
context.userId
|
|
298
|
+
)
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.error('[Tracer] Failed to end span:', error)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Process content for storage: truncate and optionally mask PII
|
|
306
|
+
*/
|
|
307
|
+
processContent(content: string, type: ContentType): string {
|
|
308
|
+
if (!this.config) {
|
|
309
|
+
return content.slice(0, 10000) // Default truncation
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const { pii } = this.config
|
|
313
|
+
|
|
314
|
+
// Apply PII masking if configured
|
|
315
|
+
let processed = content
|
|
316
|
+
if (
|
|
317
|
+
(type === 'input' && pii.maskInputs) ||
|
|
318
|
+
(type === 'output' && pii.maskOutputs)
|
|
319
|
+
) {
|
|
320
|
+
// Simple PII masking patterns
|
|
321
|
+
// Email addresses
|
|
322
|
+
processed = processed.replace(
|
|
323
|
+
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
|
|
324
|
+
'[EMAIL]'
|
|
325
|
+
)
|
|
326
|
+
// Phone numbers (various formats)
|
|
327
|
+
processed = processed.replace(
|
|
328
|
+
/\b(\+\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
|
|
329
|
+
'[PHONE]'
|
|
330
|
+
)
|
|
331
|
+
// Credit card numbers (basic pattern)
|
|
332
|
+
processed = processed.replace(
|
|
333
|
+
/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g,
|
|
334
|
+
'[CARD]'
|
|
335
|
+
)
|
|
336
|
+
// SSN (US format)
|
|
337
|
+
processed = processed.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Truncate to configured length
|
|
341
|
+
if (processed.length > pii.truncateAt) {
|
|
342
|
+
processed = processed.slice(0, pii.truncateAt) + '...[truncated]'
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return processed
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Export singleton instance
|
|
350
|
+
export const tracer = new Tracer()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
-- Migration: 001_langchain_memory.sql
|
|
2
|
+
-- Description: LangChain conversation memory persistence with multi-conversation support
|
|
3
|
+
-- Date: 2025-12-20
|
|
4
|
+
|
|
5
|
+
-- ============================================
|
|
6
|
+
-- TABLE
|
|
7
|
+
-- ============================================
|
|
8
|
+
CREATE TABLE IF NOT EXISTS public."langchain_sessions" (
|
|
9
|
+
-- Primary key
|
|
10
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
11
|
+
|
|
12
|
+
-- Relational fields (multi-tenancy)
|
|
13
|
+
"userId" TEXT NOT NULL REFERENCES public."users"(id) ON DELETE CASCADE,
|
|
14
|
+
"teamId" TEXT NOT NULL REFERENCES public."teams"(id) ON DELETE CASCADE,
|
|
15
|
+
"sessionId" TEXT NOT NULL,
|
|
16
|
+
|
|
17
|
+
-- Conversation fields
|
|
18
|
+
name TEXT DEFAULT NULL,
|
|
19
|
+
"isPinned" BOOLEAN NOT NULL DEFAULT false,
|
|
20
|
+
|
|
21
|
+
-- Memory fields
|
|
22
|
+
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
23
|
+
metadata JSONB DEFAULT '{}'::jsonb,
|
|
24
|
+
"maxMessages" INTEGER NOT NULL DEFAULT 50,
|
|
25
|
+
"expiresAt" TIMESTAMPTZ DEFAULT NULL,
|
|
26
|
+
|
|
27
|
+
-- System fields
|
|
28
|
+
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
29
|
+
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
30
|
+
|
|
31
|
+
-- Unique constraint for multi-tenant session lookup
|
|
32
|
+
CONSTRAINT langchain_sessions_unique_session UNIQUE ("userId", "teamId", "sessionId")
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
-- ============================================
|
|
36
|
+
-- COMMENTS (Documentation)
|
|
37
|
+
-- ============================================
|
|
38
|
+
COMMENT ON TABLE public."langchain_sessions" IS 'LangChain conversation memory persistence with multi-conversation support';
|
|
39
|
+
COMMENT ON COLUMN public."langchain_sessions".id IS 'Unique row identifier';
|
|
40
|
+
COMMENT ON COLUMN public."langchain_sessions"."userId" IS 'Owner user ID';
|
|
41
|
+
COMMENT ON COLUMN public."langchain_sessions"."teamId" IS 'Team context for multi-tenancy';
|
|
42
|
+
COMMENT ON COLUMN public."langchain_sessions"."sessionId" IS 'Application-level session identifier (format: {userId}-{timestamp})';
|
|
43
|
+
COMMENT ON COLUMN public."langchain_sessions".name IS 'User-friendly conversation name (auto-generated from first message if not set)';
|
|
44
|
+
COMMENT ON COLUMN public."langchain_sessions"."isPinned" IS 'Whether this conversation is pinned to top of list';
|
|
45
|
+
COMMENT ON COLUMN public."langchain_sessions".messages IS 'Serialized LangChain BaseMessage array';
|
|
46
|
+
COMMENT ON COLUMN public."langchain_sessions".metadata IS 'Optional session metadata (agent type, etc)';
|
|
47
|
+
COMMENT ON COLUMN public."langchain_sessions"."maxMessages" IS 'Sliding window limit for this session (default: 50)';
|
|
48
|
+
COMMENT ON COLUMN public."langchain_sessions"."expiresAt" IS 'TTL expiration timestamp (NULL = no expiration)';
|
|
49
|
+
COMMENT ON COLUMN public."langchain_sessions"."createdAt" IS 'Row creation timestamp';
|
|
50
|
+
COMMENT ON COLUMN public."langchain_sessions"."updatedAt" IS 'Last update timestamp';
|
|
51
|
+
|
|
52
|
+
-- ============================================
|
|
53
|
+
-- TRIGGER updatedAt
|
|
54
|
+
-- ============================================
|
|
55
|
+
DROP TRIGGER IF EXISTS langchain_sessions_set_updated_at ON public."langchain_sessions";
|
|
56
|
+
CREATE TRIGGER langchain_sessions_set_updated_at
|
|
57
|
+
BEFORE UPDATE ON public."langchain_sessions"
|
|
58
|
+
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
|
59
|
+
|
|
60
|
+
-- ============================================
|
|
61
|
+
-- INDEXES
|
|
62
|
+
-- ============================================
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_langchain_sessions_user_id ON public."langchain_sessions"("userId");
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_langchain_sessions_team_id ON public."langchain_sessions"("teamId");
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_langchain_sessions_session_id ON public."langchain_sessions"("sessionId");
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_langchain_sessions_lookup ON public."langchain_sessions"("userId", "teamId", "sessionId");
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_langchain_sessions_expires_at ON public."langchain_sessions"("expiresAt");
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_langchain_sessions_pinned ON public."langchain_sessions"("userId", "teamId", "isPinned");
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_langchain_sessions_updated ON public."langchain_sessions"("userId", "teamId", "updatedAt" DESC);
|
|
70
|
+
|
|
71
|
+
-- ============================================
|
|
72
|
+
-- RLS (Row Level Security)
|
|
73
|
+
-- ============================================
|
|
74
|
+
ALTER TABLE public."langchain_sessions" ENABLE ROW LEVEL SECURITY;
|
|
75
|
+
|
|
76
|
+
DROP POLICY IF EXISTS "langchain_sessions_owner_policy" ON public."langchain_sessions";
|
|
77
|
+
|
|
78
|
+
-- Owner can perform all operations on their own sessions
|
|
79
|
+
CREATE POLICY "langchain_sessions_owner_policy"
|
|
80
|
+
ON public."langchain_sessions"
|
|
81
|
+
FOR ALL TO authenticated
|
|
82
|
+
USING ("userId" = public.get_auth_user_id())
|
|
83
|
+
WITH CHECK ("userId" = public.get_auth_user_id());
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
-- Migration: 002_token_usage.sql
|
|
2
|
+
-- Description: Token usage tracking with cost calculation and daily aggregation
|
|
3
|
+
-- Date: 2025-12-22
|
|
4
|
+
|
|
5
|
+
-- ============================================
|
|
6
|
+
-- TABLE: Token Usage Tracking
|
|
7
|
+
-- ============================================
|
|
8
|
+
CREATE TABLE IF NOT EXISTS public."langchain_token_usage" (
|
|
9
|
+
-- Primary key
|
|
10
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
11
|
+
|
|
12
|
+
-- Relational fields (multi-tenancy)
|
|
13
|
+
"userId" TEXT NOT NULL REFERENCES public."users"(id) ON DELETE CASCADE,
|
|
14
|
+
"teamId" TEXT NOT NULL REFERENCES public."teams"(id) ON DELETE CASCADE,
|
|
15
|
+
"sessionId" TEXT,
|
|
16
|
+
|
|
17
|
+
-- Model information
|
|
18
|
+
provider TEXT NOT NULL,
|
|
19
|
+
model TEXT NOT NULL,
|
|
20
|
+
|
|
21
|
+
-- Token counts
|
|
22
|
+
"inputTokens" INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
"outputTokens" INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
"totalTokens" INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
|
|
26
|
+
-- Cost (USD, 6 decimal precision)
|
|
27
|
+
"inputCost" DECIMAL(12, 6) NOT NULL DEFAULT 0,
|
|
28
|
+
"outputCost" DECIMAL(12, 6) NOT NULL DEFAULT 0,
|
|
29
|
+
"totalCost" DECIMAL(12, 6) NOT NULL DEFAULT 0,
|
|
30
|
+
|
|
31
|
+
-- Metadata
|
|
32
|
+
"agentName" TEXT,
|
|
33
|
+
metadata JSONB DEFAULT '{}'::jsonb,
|
|
34
|
+
|
|
35
|
+
-- System fields
|
|
36
|
+
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
-- ============================================
|
|
40
|
+
-- COMMENTS (Documentation)
|
|
41
|
+
-- ============================================
|
|
42
|
+
COMMENT ON TABLE public."langchain_token_usage" IS 'Token usage tracking for LangChain agents with cost calculation';
|
|
43
|
+
COMMENT ON COLUMN public."langchain_token_usage".id IS 'Unique row identifier';
|
|
44
|
+
COMMENT ON COLUMN public."langchain_token_usage"."userId" IS 'User who triggered the AI request';
|
|
45
|
+
COMMENT ON COLUMN public."langchain_token_usage"."teamId" IS 'Team context for multi-tenancy';
|
|
46
|
+
COMMENT ON COLUMN public."langchain_token_usage"."sessionId" IS 'Optional link to langchain_sessions table';
|
|
47
|
+
COMMENT ON COLUMN public."langchain_token_usage".provider IS 'Model provider (openai, anthropic, ollama)';
|
|
48
|
+
COMMENT ON COLUMN public."langchain_token_usage".model IS 'Specific model used (gpt-4o, claude-3-5-sonnet, etc)';
|
|
49
|
+
COMMENT ON COLUMN public."langchain_token_usage"."inputTokens" IS 'Number of input/prompt tokens consumed';
|
|
50
|
+
COMMENT ON COLUMN public."langchain_token_usage"."outputTokens" IS 'Number of output/completion tokens generated';
|
|
51
|
+
COMMENT ON COLUMN public."langchain_token_usage"."totalTokens" IS 'Total tokens (input + output)';
|
|
52
|
+
COMMENT ON COLUMN public."langchain_token_usage"."inputCost" IS 'Cost for input tokens in USD';
|
|
53
|
+
COMMENT ON COLUMN public."langchain_token_usage"."outputCost" IS 'Cost for output tokens in USD';
|
|
54
|
+
COMMENT ON COLUMN public."langchain_token_usage"."totalCost" IS 'Total cost in USD';
|
|
55
|
+
COMMENT ON COLUMN public."langchain_token_usage"."agentName" IS 'Name of the agent that was used';
|
|
56
|
+
COMMENT ON COLUMN public."langchain_token_usage".metadata IS 'Additional metadata (tools used, execution time, etc)';
|
|
57
|
+
COMMENT ON COLUMN public."langchain_token_usage"."createdAt" IS 'Timestamp when the usage was recorded';
|
|
58
|
+
|
|
59
|
+
-- ============================================
|
|
60
|
+
-- INDEXES
|
|
61
|
+
-- ============================================
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_token_usage_user_team ON public."langchain_token_usage"("userId", "teamId");
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON public."langchain_token_usage"("createdAt");
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_token_usage_session_id ON public."langchain_token_usage"("sessionId") WHERE "sessionId" IS NOT NULL;
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_token_usage_provider_model ON public."langchain_token_usage"(provider, model);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_token_usage_user_created_at ON public."langchain_token_usage"("userId", "createdAt" DESC);
|
|
67
|
+
|
|
68
|
+
-- ============================================
|
|
69
|
+
-- RLS (Row Level Security)
|
|
70
|
+
-- ============================================
|
|
71
|
+
ALTER TABLE public."langchain_token_usage" ENABLE ROW LEVEL SECURITY;
|
|
72
|
+
|
|
73
|
+
DROP POLICY IF EXISTS "langchain_token_usage_owner_select" ON public."langchain_token_usage";
|
|
74
|
+
DROP POLICY IF EXISTS "langchain_token_usage_owner_insert" ON public."langchain_token_usage";
|
|
75
|
+
|
|
76
|
+
-- Users can view their own usage
|
|
77
|
+
CREATE POLICY "langchain_token_usage_owner_select"
|
|
78
|
+
ON public."langchain_token_usage"
|
|
79
|
+
FOR SELECT TO authenticated
|
|
80
|
+
USING ("userId" = public.get_auth_user_id());
|
|
81
|
+
|
|
82
|
+
-- Users can insert their own usage records
|
|
83
|
+
CREATE POLICY "langchain_token_usage_owner_insert"
|
|
84
|
+
ON public."langchain_token_usage"
|
|
85
|
+
FOR INSERT TO authenticated
|
|
86
|
+
WITH CHECK ("userId" = public.get_auth_user_id());
|
|
87
|
+
|
|
88
|
+
-- ============================================
|
|
89
|
+
-- MATERIALIZED VIEW: Daily Aggregation
|
|
90
|
+
-- ============================================
|
|
91
|
+
CREATE MATERIALIZED VIEW IF NOT EXISTS public."langchain_usage_daily" AS
|
|
92
|
+
SELECT
|
|
93
|
+
"userId",
|
|
94
|
+
"teamId",
|
|
95
|
+
provider,
|
|
96
|
+
model,
|
|
97
|
+
DATE("createdAt") as date,
|
|
98
|
+
SUM("inputTokens")::bigint as "inputTokens",
|
|
99
|
+
SUM("outputTokens")::bigint as "outputTokens",
|
|
100
|
+
SUM("totalTokens")::bigint as "totalTokens",
|
|
101
|
+
SUM("totalCost")::numeric(12, 6) as "totalCost",
|
|
102
|
+
COUNT(*)::bigint as "requestCount"
|
|
103
|
+
FROM public."langchain_token_usage"
|
|
104
|
+
GROUP BY "userId", "teamId", provider, model, DATE("createdAt");
|
|
105
|
+
|
|
106
|
+
-- Unique index for CONCURRENTLY refresh
|
|
107
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_daily_unique
|
|
108
|
+
ON public."langchain_usage_daily"("userId", "teamId", provider, model, date);
|
|
109
|
+
|
|
110
|
+
-- Comment on materialized view
|
|
111
|
+
COMMENT ON MATERIALIZED VIEW public."langchain_usage_daily" IS 'Daily aggregated token usage for dashboard performance';
|
|
112
|
+
|
|
113
|
+
-- ============================================
|
|
114
|
+
-- FUNCTION: Refresh Materialized View
|
|
115
|
+
-- ============================================
|
|
116
|
+
CREATE OR REPLACE FUNCTION public.refresh_langchain_usage_daily()
|
|
117
|
+
RETURNS void
|
|
118
|
+
LANGUAGE plpgsql
|
|
119
|
+
SECURITY DEFINER
|
|
120
|
+
SET search_path = public
|
|
121
|
+
AS $$
|
|
122
|
+
BEGIN
|
|
123
|
+
REFRESH MATERIALIZED VIEW CONCURRENTLY public."langchain_usage_daily";
|
|
124
|
+
END;
|
|
125
|
+
$$;
|
|
126
|
+
|
|
127
|
+
COMMENT ON FUNCTION public.refresh_langchain_usage_daily() IS 'Refresh the daily usage materialized view (call periodically via cron)';
|