@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.
Files changed (67) hide show
  1. package/.env.example +41 -0
  2. package/api/observability/metrics/route.ts +110 -0
  3. package/api/observability/traces/[traceId]/route.ts +398 -0
  4. package/api/observability/traces/route.ts +205 -0
  5. package/api/sessions/route.ts +332 -0
  6. package/components/observability/CollapsibleJson.tsx +71 -0
  7. package/components/observability/CompactTimeline.tsx +75 -0
  8. package/components/observability/ConversationFlow.tsx +271 -0
  9. package/components/observability/DisabledMessage.tsx +21 -0
  10. package/components/observability/FiltersPanel.tsx +82 -0
  11. package/components/observability/ObservabilityDashboard.tsx +230 -0
  12. package/components/observability/SpansList.tsx +210 -0
  13. package/components/observability/TraceDetail.tsx +335 -0
  14. package/components/observability/TraceStatusBadge.tsx +39 -0
  15. package/components/observability/TracesTable.tsx +97 -0
  16. package/components/observability/index.ts +7 -0
  17. package/docs/01-getting-started/01-overview.md +196 -0
  18. package/docs/01-getting-started/02-installation.md +368 -0
  19. package/docs/01-getting-started/03-configuration.md +794 -0
  20. package/docs/02-core-concepts/01-architecture.md +566 -0
  21. package/docs/02-core-concepts/02-agents.md +597 -0
  22. package/docs/02-core-concepts/03-tools.md +689 -0
  23. package/docs/03-orchestration/01-graph-orchestrator.md +809 -0
  24. package/docs/03-orchestration/02-legacy-react.md +650 -0
  25. package/docs/04-advanced/01-observability.md +645 -0
  26. package/docs/04-advanced/02-token-tracking.md +469 -0
  27. package/docs/04-advanced/03-streaming.md +476 -0
  28. package/docs/04-advanced/04-guardrails.md +597 -0
  29. package/docs/05-reference/01-api-reference.md +1403 -0
  30. package/docs/05-reference/02-customization.md +646 -0
  31. package/docs/05-reference/03-examples.md +881 -0
  32. package/docs/index.md +85 -0
  33. package/hooks/observability/useMetrics.ts +31 -0
  34. package/hooks/observability/useTraceDetail.ts +48 -0
  35. package/hooks/observability/useTraces.ts +59 -0
  36. package/lib/agent-factory.ts +354 -0
  37. package/lib/agent-helpers.ts +201 -0
  38. package/lib/db-memory-store.ts +417 -0
  39. package/lib/graph/index.ts +58 -0
  40. package/lib/graph/nodes/combiner.ts +399 -0
  41. package/lib/graph/nodes/router.ts +440 -0
  42. package/lib/graph/orchestrator-graph.ts +386 -0
  43. package/lib/graph/prompts/combiner.md +131 -0
  44. package/lib/graph/prompts/router.md +193 -0
  45. package/lib/graph/types.ts +365 -0
  46. package/lib/guardrails.ts +230 -0
  47. package/lib/index.ts +44 -0
  48. package/lib/logger.ts +70 -0
  49. package/lib/memory-store.ts +168 -0
  50. package/lib/message-serializer.ts +110 -0
  51. package/lib/prompt-renderer.ts +94 -0
  52. package/lib/providers.ts +226 -0
  53. package/lib/streaming.ts +232 -0
  54. package/lib/token-tracker.ts +298 -0
  55. package/lib/tools-builder.ts +192 -0
  56. package/lib/tracer-callbacks.ts +342 -0
  57. package/lib/tracer.ts +350 -0
  58. package/migrations/001_langchain_memory.sql +83 -0
  59. package/migrations/002_token_usage.sql +127 -0
  60. package/migrations/003_observability.sql +257 -0
  61. package/package.json +28 -0
  62. package/plugin.config.ts +170 -0
  63. package/presets/lib/langchain.config.ts.preset +142 -0
  64. package/presets/templates/sector7/ai-observability/[traceId]/page.tsx +91 -0
  65. package/presets/templates/sector7/ai-observability/page.tsx +54 -0
  66. package/types/langchain.types.ts +274 -0
  67. 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)';