@percepta/create 3.4.2 → 3.4.3
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/package.json +1 -1
- package/templates/webapp/AGENTS.md +1 -1
- package/templates/webapp/agent-skills/inngest.md +5 -5
- package/templates/webapp/agent-skills/langfuse.md +4 -4
- package/templates/webapp/agent-skills/llm.md +1 -1
- package/templates/webapp/package.json.template +5 -12
- package/templates/webapp/src/app/api/inngest/route.ts +12 -22
- package/templates/webapp/src/instrumentation.ts +2 -63
- package/templates/webapp/src/server/trpc.ts +6 -18
- package/templates/webapp/src/services/AuthContextService.ts +7 -59
- package/templates/webapp/src/services/inngest/InngestService.ts +14 -62
- package/templates/webapp/src/services/langfuse/LangfuseService.ts +9 -77
- package/templates/webapp/src/services/llm/LLMService.ts +10 -88
- package/templates/webapp/src/services/logger/AppLogger.ts +3 -48
- package/templates/webapp/src/utils/syncInngestApp.ts +4 -56
- package/templates/webapp/src/services/inngest/InngestFunctionCollection.ts +0 -5
- package/templates/webapp/src/services/llm/LlmProviderService.ts +0 -85
package/package.json
CHANGED
|
@@ -158,7 +158,7 @@ logger.info({ safe: { requestId }, unsafe: { email } }, "User action completed")
|
|
|
158
158
|
logger.error({ safe: { documentId } }, "Processing failed", error);
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
-
The app's logger is initialized in `src/services/logger/AppLogger.ts` using `
|
|
161
|
+
The app's logger is initialized in `src/services/logger/AppLogger.ts` using `createLoggerRuntime()` from this package. It uses `AsyncLocalStorage` for automatic request context propagation.
|
|
162
162
|
|
|
163
163
|
### @percepta/utils — Shared Utilities
|
|
164
164
|
|
|
@@ -43,14 +43,14 @@ schemas validate `event.data`, so do not wrap payload schemas in another
|
|
|
43
43
|
|
|
44
44
|
### 1. Create a function collection
|
|
45
45
|
|
|
46
|
-
Group related functions into a collection class that implements `
|
|
46
|
+
Group related functions into a collection class that implements `PerceptaInngestFunctionCollection`:
|
|
47
47
|
|
|
48
48
|
```typescript
|
|
49
|
+
import { type PerceptaInngestFunctionCollection } from "@percepta/inngest";
|
|
49
50
|
import { type InngestFunction } from "inngest";
|
|
50
|
-
import { type InngestFunctionCollection } from "../InngestFunctionCollection";
|
|
51
51
|
import { InngestService } from "../InngestService";
|
|
52
52
|
|
|
53
|
-
export class DocumentFunctions implements
|
|
53
|
+
export class DocumentFunctions implements PerceptaInngestFunctionCollection {
|
|
54
54
|
private inngestService = InngestService.create();
|
|
55
55
|
|
|
56
56
|
public get functions(): InngestFunction.Like[] {
|
|
@@ -85,9 +85,9 @@ export class DocumentFunctions implements InngestFunctionCollection {
|
|
|
85
85
|
Add your collection to the `functionCollections` array in the Inngest serve endpoint:
|
|
86
86
|
|
|
87
87
|
```typescript
|
|
88
|
-
const functionCollections:
|
|
88
|
+
const functionCollections: PerceptaInngestFunctionCollection[] = [
|
|
89
89
|
new DocumentFunctions(),
|
|
90
|
-
]
|
|
90
|
+
];
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
## Sending Events
|
|
@@ -21,14 +21,14 @@ Langfuse is an open-source LLM observability platform. It captures traces, spans
|
|
|
21
21
|
The template uses Next.js's instrumentation hook (called on server startup) to bootstrap OTEL with both the environment collector and optional Langfuse:
|
|
22
22
|
|
|
23
23
|
```typescript
|
|
24
|
-
import { LangfuseSpanProcessor } from "@langfuse/otel";
|
|
25
|
-
import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
|
|
26
24
|
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
|
25
|
+
import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
|
|
26
|
+
import { createLangfuseSpanProcessor } from "@percepta/ai";
|
|
27
27
|
|
|
28
28
|
const sdk = new NodeSDK({
|
|
29
29
|
spanProcessors: [
|
|
30
30
|
new tracing.BatchSpanProcessor(otlpTraceExporter),
|
|
31
|
-
|
|
31
|
+
createLangfuseSpanProcessor(env, logger),
|
|
32
32
|
],
|
|
33
33
|
instrumentations: [getNodeAutoInstrumentations()],
|
|
34
34
|
});
|
|
@@ -37,7 +37,7 @@ sdk.start();
|
|
|
37
37
|
|
|
38
38
|
- `getNodeAutoInstrumentations()` automatically instruments HTTP calls, database queries, and other standard Node.js operations.
|
|
39
39
|
- The OTLP span processor forwards general server traces to the environment collector.
|
|
40
|
-
-
|
|
40
|
+
- The shared Langfuse span processor forwards only AI SDK spans to Langfuse when all `LANGFUSE_*` variables are set. General HTTP, DB, and server spans stay in the OTEL/LGTM pipeline and are not sent to Langfuse by default.
|
|
41
41
|
|
|
42
42
|
**You do not need to manually instrument LLM calls.** Use `LLMService` from `src/services/llm/LLMService.ts`; it calls the Vercel AI SDK with telemetry enabled and provider/model metadata attached.
|
|
43
43
|
|
|
@@ -30,7 +30,7 @@ const stream = LLMService.create().streamText({
|
|
|
30
30
|
|
|
31
31
|
## Providers
|
|
32
32
|
|
|
33
|
-
`
|
|
33
|
+
The shared `@percepta/ai` provider helper chooses a provider at call time:
|
|
34
34
|
|
|
35
35
|
1. `LLM_PROVIDER`, when explicitly set.
|
|
36
36
|
2. Anthropic when `ANTHROPIC_API_KEY` is available.
|
|
@@ -33,8 +33,6 @@
|
|
|
33
33
|
"test:watch": "vitest"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@ai-sdk/anthropic": "^2.0.23",
|
|
37
|
-
"@ai-sdk/openai": "^2.0.23",
|
|
38
36
|
"@aws-sdk/client-s3": "^3.888.0",
|
|
39
37
|
"@aws-sdk/client-secrets-manager": "^3.914.0",
|
|
40
38
|
"@aws-sdk/client-sts": "^3.913.0",
|
|
@@ -48,9 +46,6 @@
|
|
|
48
46
|
"@grafana/faro-web-sdk": "^1.14.0",
|
|
49
47
|
"@grafana/faro-web-tracing": "^1.14.0",
|
|
50
48
|
"@hookform/resolvers": "^5.2.2",
|
|
51
|
-
"@inngest/middleware-validation": "^0.0.5",
|
|
52
|
-
"@langfuse/otel": "^4.0.0",
|
|
53
|
-
"@langfuse/tracing": "^4.0.0",
|
|
54
49
|
"@mantine/hooks": "^8.3.1",
|
|
55
50
|
"@next/env": "^15.3.5",
|
|
56
51
|
"@opentelemetry/api": "^1.9.0",
|
|
@@ -58,10 +53,12 @@
|
|
|
58
53
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.203.0",
|
|
59
54
|
"@opentelemetry/sdk-node": "^0.203.0",
|
|
60
55
|
"@__REPO_NAME__/auth": "workspace:*",
|
|
61
|
-
"@percepta/access-control": "0.
|
|
56
|
+
"@percepta/access-control": "0.7.0",
|
|
57
|
+
"@percepta/ai": "0.1.0",
|
|
62
58
|
"@percepta/design": "0.3.2",
|
|
63
|
-
"@percepta/
|
|
64
|
-
"@percepta/
|
|
59
|
+
"@percepta/inngest": "0.1.0",
|
|
60
|
+
"@percepta/logger": "0.1.0",
|
|
61
|
+
"@percepta/next-utils": "0.2.0",
|
|
65
62
|
"@percepta/utils": "0.1.10",
|
|
66
63
|
"@radix-ui/react-slot": "^1.2.3",
|
|
67
64
|
"@tanstack/react-query": "^5.81.5",
|
|
@@ -69,7 +66,6 @@
|
|
|
69
66
|
"@trpc/client": "^11.4.3",
|
|
70
67
|
"@trpc/server": "^11.4.3",
|
|
71
68
|
"@trpc/tanstack-react-query": "^11.4.3",
|
|
72
|
-
"ai": "^5.0.24",
|
|
73
69
|
"better-auth": "^1.6.4",
|
|
74
70
|
"clsx": "^2.1.1",
|
|
75
71
|
"date-fns": "^4.1.0",
|
|
@@ -80,15 +76,12 @@
|
|
|
80
76
|
"import-in-the-middle": "^1.14.2",
|
|
81
77
|
"inngest": "^3.44.3",
|
|
82
78
|
"keyed-type-union": "^0.1.0",
|
|
83
|
-
"langfuse": "^3.38.6",
|
|
84
79
|
"lodash-es": "^4.17.21",
|
|
85
80
|
"lucide-react": "^0.542.0",
|
|
86
81
|
"mime-types": "^3.0.1",
|
|
87
82
|
"next": "^15.3.5",
|
|
88
83
|
"numeral": "^2.0.6",
|
|
89
84
|
"pg": "^8.16.3",
|
|
90
|
-
"pino": "^10.1.0",
|
|
91
|
-
"pino-pretty": "^13.1.3",
|
|
92
85
|
"pluralize": "^8.0.0",
|
|
93
86
|
"react": "^19.0.0",
|
|
94
87
|
"react-dom": "^19.0.0",
|
|
@@ -1,31 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
createPerceptaInngestNextRouteHandlers,
|
|
3
|
+
type PerceptaInngestFunctionCollection,
|
|
4
|
+
} from "@percepta/inngest";
|
|
4
5
|
import { InngestService } from "../../../services/inngest/InngestService";
|
|
5
6
|
|
|
6
7
|
export const dynamic = "force-dynamic";
|
|
7
8
|
|
|
8
|
-
const functionCollections:
|
|
9
|
+
const functionCollections: PerceptaInngestFunctionCollection[] = [];
|
|
9
10
|
|
|
10
11
|
// InngestService.create() reads env vars and throws if INNGEST_BASE_URL is
|
|
11
12
|
// unset. Defer initialization until request time so `next build` can compile
|
|
12
13
|
// this route without those vars present.
|
|
13
|
-
|
|
14
|
+
const inngestHandlers = createPerceptaInngestNextRouteHandlers({
|
|
15
|
+
getService: () => InngestService.create(),
|
|
16
|
+
getFunctions: () => functionCollections.flatMap(({ functions }) => functions),
|
|
17
|
+
});
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (cachedHandlers == null) {
|
|
19
|
-
const inngestService = InngestService.create();
|
|
20
|
-
cachedHandlers = serve({
|
|
21
|
-
client: inngestService.client,
|
|
22
|
-
functions: functionCollections.flatMap(({ functions }) => functions),
|
|
23
|
-
signingKey: inngestService.signingKey,
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
return cachedHandlers;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const GET: Handlers["GET"] = (...args) => getHandlers().GET(...args);
|
|
30
|
-
export const POST: Handlers["POST"] = (...args) => getHandlers().POST(...args);
|
|
31
|
-
export const PUT: Handlers["PUT"] = (...args) => getHandlers().PUT(...args);
|
|
19
|
+
export const GET = inngestHandlers.GET;
|
|
20
|
+
export const POST = inngestHandlers.POST;
|
|
21
|
+
export const PUT = inngestHandlers.PUT;
|
|
@@ -1,52 +1,12 @@
|
|
|
1
|
-
import { LangfuseSpanProcessor } from "@langfuse/otel";
|
|
2
1
|
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
|
3
2
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
|
4
3
|
import { NodeSDK, tracing } from "@opentelemetry/sdk-node";
|
|
4
|
+
import { createLangfuseSpanProcessor } from "@percepta/ai";
|
|
5
5
|
import { compact } from "lodash-es";
|
|
6
6
|
import { getEnvConfig } from "./config/getEnvConfig";
|
|
7
7
|
import { getLogger } from "./services/logger/AppLogger";
|
|
8
8
|
|
|
9
9
|
type SpanProcessor = tracing.SpanProcessor;
|
|
10
|
-
type ReadableSpan = Parameters<SpanProcessor["onEnd"]>[0];
|
|
11
|
-
type OnStartArgs = Parameters<SpanProcessor["onStart"]>;
|
|
12
|
-
|
|
13
|
-
class FilteringSpanProcessor implements SpanProcessor {
|
|
14
|
-
public constructor(
|
|
15
|
-
private delegate: SpanProcessor,
|
|
16
|
-
private shouldExport: (span: ReadableSpan) => boolean,
|
|
17
|
-
) {}
|
|
18
|
-
|
|
19
|
-
public forceFlush(): Promise<void> {
|
|
20
|
-
return this.delegate.forceFlush();
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
public onStart(...args: OnStartArgs): void {
|
|
24
|
-
this.delegate.onStart(...args);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
public onEnd(span: ReadableSpan): void {
|
|
28
|
-
if (this.shouldExport(span)) {
|
|
29
|
-
this.delegate.onEnd(span);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
public shutdown(): Promise<void> {
|
|
34
|
-
return this.delegate.shutdown();
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function isAiSdkSpan(span: ReadableSpan): boolean {
|
|
39
|
-
const operationId = span.attributes["ai.operationId"];
|
|
40
|
-
if (typeof operationId === "string" && operationId.startsWith("ai.")) {
|
|
41
|
-
return true;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (span.name.startsWith("ai.")) {
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return Object.keys(span.attributes).some((key) => key.startsWith("gen_ai."));
|
|
49
|
-
}
|
|
50
10
|
|
|
51
11
|
function getOtlpTracesEndpoint(): string | undefined {
|
|
52
12
|
const {
|
|
@@ -90,28 +50,7 @@ function getOtlpSpanProcessor(): tracing.BatchSpanProcessor | undefined {
|
|
|
90
50
|
}
|
|
91
51
|
|
|
92
52
|
function getLangfuseSpanProcessor(): SpanProcessor | undefined {
|
|
93
|
-
|
|
94
|
-
LANGFUSE_BASE_URL: baseUrl,
|
|
95
|
-
LANGFUSE_PUBLIC_KEY: publicKey,
|
|
96
|
-
LANGFUSE_SECRET_KEY: secretKey,
|
|
97
|
-
} = getEnvConfig();
|
|
98
|
-
if (!baseUrl || !publicKey || !secretKey) {
|
|
99
|
-
getLogger().debug(
|
|
100
|
-
undefined,
|
|
101
|
-
"Langfuse environment is incomplete. Skipping Langfuse OpenTelemetry.",
|
|
102
|
-
);
|
|
103
|
-
return undefined;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
getLogger().debug(undefined, "Registering Langfuse OpenTelemetry.");
|
|
107
|
-
return new FilteringSpanProcessor(
|
|
108
|
-
new LangfuseSpanProcessor({
|
|
109
|
-
baseUrl,
|
|
110
|
-
publicKey,
|
|
111
|
-
secretKey,
|
|
112
|
-
}),
|
|
113
|
-
isAiSdkSpan,
|
|
114
|
-
);
|
|
53
|
+
return createLangfuseSpanProcessor(getEnvConfig(), getLogger());
|
|
115
54
|
}
|
|
116
55
|
|
|
117
56
|
const spanProcessors: tracing.SpanProcessor[] = compact([
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
createRequireApplicationAccess,
|
|
4
|
-
createRequirePermission,
|
|
5
|
-
} from "@percepta/access-control/trpc";
|
|
1
|
+
import type { SubjectRef } from "@percepta/access-control";
|
|
2
|
+
import { createAppAccessControlMiddlewares } from "@percepta/access-control/trpc";
|
|
6
3
|
import { TRPCError, initTRPC } from "@trpc/server";
|
|
7
4
|
import superjson from "superjson";
|
|
8
5
|
import { accessManifest } from "../access/access.manifest";
|
|
@@ -25,12 +22,6 @@ export interface ProtectedContext extends Context {
|
|
|
25
22
|
session: BetterAuthSession;
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
const lazyPermissionClient = {
|
|
29
|
-
can(check) {
|
|
30
|
-
return getAccessControl().permissions.can(check);
|
|
31
|
-
},
|
|
32
|
-
} satisfies Pick<PermissionClient, "can">;
|
|
33
|
-
|
|
34
25
|
function getCurrentSubject(ctx: Context): SubjectRef | null {
|
|
35
26
|
const userId = ctx.session?.user.id;
|
|
36
27
|
return userId == null ? null : toUserSubject(userId);
|
|
@@ -76,17 +67,14 @@ const requireAuthenticatedUser = procedure.use(
|
|
|
76
67
|
},
|
|
77
68
|
);
|
|
78
69
|
|
|
79
|
-
const requireApplicationAccess =
|
|
80
|
-
|
|
81
|
-
|
|
70
|
+
const { requireApplicationAccess, requirePermission } =
|
|
71
|
+
createAppAccessControlMiddlewares<ProtectedContext>({
|
|
72
|
+
getAccessControl,
|
|
82
73
|
getSubject: getCurrentSubject,
|
|
83
74
|
manifest: accessManifest,
|
|
84
75
|
});
|
|
85
76
|
|
|
86
|
-
export
|
|
87
|
-
accessControl: lazyPermissionClient,
|
|
88
|
-
getSubject: getCurrentSubject,
|
|
89
|
-
});
|
|
77
|
+
export { requirePermission };
|
|
90
78
|
|
|
91
79
|
export const protectedProcedure = requireAuthenticatedUser;
|
|
92
80
|
|
|
@@ -1,63 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
createAuthContextServiceFactory,
|
|
3
|
+
type AuthContextService as PerceptaAuthContextService,
|
|
4
|
+
} from "@percepta/next-utils";
|
|
3
5
|
import type { BetterAuthSession } from "../lib/auth";
|
|
4
6
|
|
|
5
7
|
export type AuthSession = BetterAuthSession;
|
|
6
8
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (AuthContextService.SINGLETON == null) {
|
|
11
|
-
AuthContextService.SINGLETON = new AuthContextService();
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
return AuthContextService.SINGLETON;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
private authAsyncLocalStorage = new AsyncLocalStorage<LocalStorage>();
|
|
18
|
-
|
|
19
|
-
private constructor() {}
|
|
20
|
-
|
|
21
|
-
public withSession<TReturn>(
|
|
22
|
-
session: AuthSession,
|
|
23
|
-
callback: () => TReturn,
|
|
24
|
-
): TReturn {
|
|
25
|
-
// Set user email on the active OpenTelemetry span
|
|
26
|
-
const activeSpan = trace.getActiveSpan();
|
|
27
|
-
if (
|
|
28
|
-
activeSpan != null &&
|
|
29
|
-
session.user.email != null &&
|
|
30
|
-
session.user.id != null
|
|
31
|
-
) {
|
|
32
|
-
activeSpan.setAttribute("user.email", session.user.email);
|
|
33
|
-
activeSpan.setAttribute("user.id", session.user.id);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return this.authAsyncLocalStorage.run<TReturn>({ session }, () =>
|
|
37
|
-
callback(),
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
public getSession(): AuthSession | undefined {
|
|
42
|
-
const context = this.authAsyncLocalStorage.getStore();
|
|
43
|
-
if (context == null) {
|
|
44
|
-
return undefined;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const { session } = context;
|
|
48
|
-
return session;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
public getSessionOrThrow(): AuthSession {
|
|
52
|
-
const session = this.getSession();
|
|
53
|
-
if (session == null) {
|
|
54
|
-
throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return session;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface LocalStorage {
|
|
62
|
-
session: AuthSession;
|
|
63
|
-
}
|
|
9
|
+
export const AuthContextService =
|
|
10
|
+
createAuthContextServiceFactory<AuthSession>();
|
|
11
|
+
export type AuthContextService = PerceptaAuthContextService<AuthSession>;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
createPerceptaInngestServiceFactory,
|
|
3
|
+
type PerceptaInngest,
|
|
4
|
+
type PerceptaInngestService,
|
|
5
|
+
} from "@percepta/inngest";
|
|
6
|
+
import { EventSchemas } from "inngest";
|
|
3
7
|
import { getEnvConfig } from "../../config/getEnvConfig";
|
|
4
8
|
import { AppEvents } from "./events/AppEvents";
|
|
5
9
|
|
|
@@ -8,64 +12,12 @@ export interface PollOpts {
|
|
|
8
12
|
delayMs: number;
|
|
9
13
|
}
|
|
10
14
|
|
|
11
|
-
export interface AppInngest extends AppInngestInternal {
|
|
12
|
-
// Externally, we should never call createFunction on the client interface.
|
|
13
|
-
createFunction: never;
|
|
14
|
-
}
|
|
15
|
-
type AppInngestInternal = Inngest<{
|
|
16
|
-
id: string;
|
|
17
|
-
schemas: typeof EVENT_SCHEMAS;
|
|
18
|
-
}>;
|
|
19
|
-
|
|
20
|
-
export class InngestService {
|
|
21
|
-
private static SINGLETON: InngestService | undefined;
|
|
22
|
-
public static create(): InngestService {
|
|
23
|
-
if (InngestService.SINGLETON == null) {
|
|
24
|
-
InngestService.SINGLETON = new InngestService();
|
|
25
|
-
}
|
|
26
|
-
return InngestService.SINGLETON;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
private _signingKey: string | undefined;
|
|
30
|
-
private inngest: AppInngestInternal;
|
|
31
|
-
|
|
32
|
-
private constructor() {
|
|
33
|
-
const {
|
|
34
|
-
INNGEST_BASE_URL: baseUrl,
|
|
35
|
-
INNGEST_SIGNING_KEY: signingKey,
|
|
36
|
-
INNGEST_EVENT_KEY: eventKey,
|
|
37
|
-
} = getEnvConfig();
|
|
38
|
-
|
|
39
|
-
if (baseUrl == null) {
|
|
40
|
-
throw new Error("INNGEST_BASE_URL is not set.");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
this._signingKey = signingKey;
|
|
44
|
-
this.inngest = new Inngest({
|
|
45
|
-
id: "__APP_NAME__",
|
|
46
|
-
eventKey,
|
|
47
|
-
baseUrl,
|
|
48
|
-
schemas: EVENT_SCHEMAS,
|
|
49
|
-
middleware: [validationMiddleware()],
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
public get client(): AppInngest {
|
|
54
|
-
return this.inngest as never;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
public get signingKey(): string | undefined {
|
|
58
|
-
return this._signingKey;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
public createFunction: Inngest.CreateFunction<AppInngest> = (
|
|
62
|
-
options,
|
|
63
|
-
trigger,
|
|
64
|
-
handler,
|
|
65
|
-
) => {
|
|
66
|
-
// TODO: Add context plumbing here.
|
|
67
|
-
return this.inngest.createFunction(options, trigger, handler) as never;
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
15
|
const EVENT_SCHEMAS = new EventSchemas().fromSchema(AppEvents);
|
|
16
|
+
|
|
17
|
+
export type AppInngest = PerceptaInngest<typeof EVENT_SCHEMAS>;
|
|
18
|
+
export const InngestService = createPerceptaInngestServiceFactory({
|
|
19
|
+
appId: "__APP_NAME__",
|
|
20
|
+
getEnv: getEnvConfig,
|
|
21
|
+
schemas: EVENT_SCHEMAS,
|
|
22
|
+
});
|
|
23
|
+
export type InngestService = PerceptaInngestService<typeof EVENT_SCHEMAS>;
|
|
@@ -1,80 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createPerceptaLangfuseServiceFactory,
|
|
3
|
+
type PerceptaLangfuseService,
|
|
4
|
+
} from "@percepta/ai";
|
|
2
5
|
import { getEnvConfig } from "../../config/getEnvConfig";
|
|
3
6
|
import { getLogger } from "../logger/AppLogger";
|
|
4
7
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
LangfuseService.SINGLETON = new LangfuseService();
|
|
11
|
-
}
|
|
12
|
-
return LangfuseService.SINGLETON;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
private readonly client: Langfuse | undefined;
|
|
16
|
-
|
|
17
|
-
private constructor() {
|
|
18
|
-
const config = getEnvConfig();
|
|
19
|
-
const baseUrl = config.LANGFUSE_BASE_URL;
|
|
20
|
-
const publicKey = config.LANGFUSE_PUBLIC_KEY;
|
|
21
|
-
const secretKey = config.LANGFUSE_SECRET_KEY;
|
|
22
|
-
|
|
23
|
-
if (baseUrl && publicKey && secretKey) {
|
|
24
|
-
this.client = new Langfuse({
|
|
25
|
-
baseUrl,
|
|
26
|
-
publicKey,
|
|
27
|
-
secretKey,
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Check if Langfuse is configured.
|
|
34
|
-
*/
|
|
35
|
-
public isConfigured(): boolean {
|
|
36
|
-
return this.client != null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Update a Langfuse trace with metadata.
|
|
41
|
-
* Uses trace.update() which merges with existing metadata.
|
|
42
|
-
*
|
|
43
|
-
* @param traceId - The trace ID (same as documentPipelineRuns.id)
|
|
44
|
-
* @param metadata - The metadata to save (must be JSON-serializable)
|
|
45
|
-
*/
|
|
46
|
-
public async updateTraceMetadata<T extends object>(
|
|
47
|
-
traceId: string,
|
|
48
|
-
metadata: T,
|
|
49
|
-
): Promise<void> {
|
|
50
|
-
if (!this.isConfigured()) {
|
|
51
|
-
getLogger().warn(
|
|
52
|
-
{ safe: { traceId } },
|
|
53
|
-
"[LangfuseService] Langfuse not configured, skipping trace update",
|
|
54
|
-
);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Capture client after isConfigured() guard for type narrowing
|
|
59
|
-
const client = this.client!;
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
// Attach to existing trace by ID and update metadata
|
|
63
|
-
const trace = client.trace({ id: traceId });
|
|
64
|
-
trace.update({ metadata });
|
|
65
|
-
|
|
66
|
-
// Flush to ensure the update is sent
|
|
67
|
-
await client.flushAsync();
|
|
68
|
-
|
|
69
|
-
getLogger().info(
|
|
70
|
-
{ safe: { traceId } },
|
|
71
|
-
"[LangfuseService] Updated trace with metadata",
|
|
72
|
-
);
|
|
73
|
-
} catch (error) {
|
|
74
|
-
getLogger().error(
|
|
75
|
-
{ safe: { traceId }, unsafe: { error: String(error) } },
|
|
76
|
-
"[LangfuseService] Failed to update trace metadata",
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
8
|
+
export const LangfuseService = createPerceptaLangfuseServiceFactory({
|
|
9
|
+
getConfig: getEnvConfig,
|
|
10
|
+
getLogger,
|
|
11
|
+
});
|
|
12
|
+
export type LangfuseService = PerceptaLangfuseService;
|
|
@@ -1,88 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
type
|
|
11
|
-
modelId?: string;
|
|
12
|
-
provider?: LlmProviderName;
|
|
13
|
-
telemetryFunctionId?: string;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export class LLMService {
|
|
17
|
-
private static SINGLETON: LLMService | undefined;
|
|
18
|
-
|
|
19
|
-
public static create(): LLMService {
|
|
20
|
-
if (LLMService.SINGLETON == null) {
|
|
21
|
-
LLMService.SINGLETON = new LLMService(LlmProviderService.create());
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
return LLMService.SINGLETON;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
private constructor(private llmProviderService: LlmProviderService) {}
|
|
28
|
-
|
|
29
|
-
public generateText(
|
|
30
|
-
options: GenerateTextOptions,
|
|
31
|
-
): ReturnType<typeof generateText> {
|
|
32
|
-
const { modelId, provider, telemetryFunctionId, ...generateOptions } =
|
|
33
|
-
options;
|
|
34
|
-
const selection = this.llmProviderService.getLanguageModel({
|
|
35
|
-
modelId,
|
|
36
|
-
provider,
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
const aiOptions = {
|
|
40
|
-
...generateOptions,
|
|
41
|
-
model: selection.model,
|
|
42
|
-
experimental_telemetry: {
|
|
43
|
-
...generateOptions.experimental_telemetry,
|
|
44
|
-
isEnabled: generateOptions.experimental_telemetry?.isEnabled ?? true,
|
|
45
|
-
functionId:
|
|
46
|
-
telemetryFunctionId ??
|
|
47
|
-
generateOptions.experimental_telemetry?.functionId ??
|
|
48
|
-
"llm.generateText",
|
|
49
|
-
metadata: {
|
|
50
|
-
...generateOptions.experimental_telemetry?.metadata,
|
|
51
|
-
llmProvider: selection.provider,
|
|
52
|
-
llmModel: selection.modelId,
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
} as Parameters<typeof generateText>[0];
|
|
56
|
-
|
|
57
|
-
return generateText(aiOptions);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
public streamText(options: StreamTextOptions): ReturnType<typeof streamText> {
|
|
61
|
-
const { modelId, provider, telemetryFunctionId, ...streamOptions } =
|
|
62
|
-
options;
|
|
63
|
-
const selection = this.llmProviderService.getLanguageModel({
|
|
64
|
-
modelId,
|
|
65
|
-
provider,
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
const aiOptions = {
|
|
69
|
-
...streamOptions,
|
|
70
|
-
model: selection.model,
|
|
71
|
-
experimental_telemetry: {
|
|
72
|
-
...streamOptions.experimental_telemetry,
|
|
73
|
-
isEnabled: streamOptions.experimental_telemetry?.isEnabled ?? true,
|
|
74
|
-
functionId:
|
|
75
|
-
telemetryFunctionId ??
|
|
76
|
-
streamOptions.experimental_telemetry?.functionId ??
|
|
77
|
-
"llm.streamText",
|
|
78
|
-
metadata: {
|
|
79
|
-
...streamOptions.experimental_telemetry?.metadata,
|
|
80
|
-
llmProvider: selection.provider,
|
|
81
|
-
llmModel: selection.modelId,
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
} as Parameters<typeof streamText>[0];
|
|
85
|
-
|
|
86
|
-
return streamText(aiOptions);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
createPerceptaLlmServiceFactory,
|
|
3
|
+
type PerceptaLlmService,
|
|
4
|
+
} from "@percepta/ai";
|
|
5
|
+
import { getEnvConfig } from "../../config/getEnvConfig";
|
|
6
|
+
|
|
7
|
+
export const LLMService = createPerceptaLlmServiceFactory({
|
|
8
|
+
getConfig: getEnvConfig,
|
|
9
|
+
});
|
|
10
|
+
export type LLMService = PerceptaLlmService;
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type MosaicLogger,
|
|
3
|
-
createLogFactory,
|
|
4
|
-
createTracerFactory,
|
|
5
|
-
} from "@percepta/logger";
|
|
6
|
-
import { assertNever } from "@percepta/utils";
|
|
7
|
-
import pino, { type Logger } from "pino";
|
|
8
|
-
import pretty from "pino-pretty";
|
|
1
|
+
import { createLoggerRuntime } from "@percepta/logger";
|
|
9
2
|
import { getEnvConfig } from "../../config/getEnvConfig";
|
|
10
3
|
|
|
11
4
|
/**
|
|
@@ -19,43 +12,5 @@ import { getEnvConfig } from "../../config/getEnvConfig";
|
|
|
19
12
|
* All logs automatically include request context (reqId, method, path, host)
|
|
20
13
|
* when called within a request scope established by withLogContext().
|
|
21
14
|
*/
|
|
22
|
-
export const { getLogger, withLogContext } =
|
|
23
|
-
|
|
24
|
-
() => new AsyncLocalStorage<MosaicLogger>(),
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Tracer for request context propagation.
|
|
29
|
-
* Uses AsyncLocalStorage to pass trace IDs through async boundaries.
|
|
30
|
-
*/
|
|
31
|
-
export const { getTracer, withTracer } = createTracerFactory(
|
|
32
|
-
() => new AsyncLocalStorage(),
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
// TODO(@dzhao): Generalize this and move to createPinoInstance.ts.
|
|
36
|
-
function createBasePinoInstance(): Logger {
|
|
37
|
-
const { NODE_ENV: nodeEnv, LOG_LEVEL: level } = getEnvConfig();
|
|
38
|
-
|
|
39
|
-
switch (nodeEnv) {
|
|
40
|
-
case "production":
|
|
41
|
-
return pino({
|
|
42
|
-
level,
|
|
43
|
-
formatters: {
|
|
44
|
-
level: (label) => ({ level: label }),
|
|
45
|
-
},
|
|
46
|
-
});
|
|
47
|
-
case "development":
|
|
48
|
-
case "test":
|
|
49
|
-
// Development: use pino-pretty synchronously (avoids worker thread issues with Next.js/Turbopack)
|
|
50
|
-
return pino(
|
|
51
|
-
{ level },
|
|
52
|
-
pretty({
|
|
53
|
-
colorize: true,
|
|
54
|
-
translateTime: "SYS:standard",
|
|
55
|
-
ignore: "pid,hostname",
|
|
56
|
-
}),
|
|
57
|
-
);
|
|
58
|
-
default:
|
|
59
|
-
return assertNever(nodeEnv);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
15
|
+
export const { getLogger, getTracer, withLogContext, withTracer } =
|
|
16
|
+
createLoggerRuntime({ env: getEnvConfig() });
|
|
@@ -1,62 +1,10 @@
|
|
|
1
|
+
import { syncPerceptaInngestApp } from "@percepta/inngest";
|
|
1
2
|
import { getEnvConfig } from "../config/getEnvConfig";
|
|
2
3
|
import { getLogger } from "../services/logger/AppLogger";
|
|
3
4
|
|
|
4
5
|
export async function syncInngestApp(): Promise<void> {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
APP_BASE_URL: appBaseUrl,
|
|
9
|
-
INNGEST_APP_URL,
|
|
10
|
-
} = getEnvConfig();
|
|
11
|
-
|
|
12
|
-
// Cascade: explicit INNGEST_APP_URL > derived from APP_BASE_URL > localhost fallback
|
|
13
|
-
const appUrl =
|
|
14
|
-
INNGEST_APP_URL ??
|
|
15
|
-
(appBaseUrl
|
|
16
|
-
? `${appBaseUrl}/api/inngest`
|
|
17
|
-
: "http://localhost:3000/api/inngest");
|
|
18
|
-
|
|
19
|
-
if (skipSync) {
|
|
20
|
-
getLogger().info(undefined, "SKIP_INNGEST_SYNC=true, skipping auto-sync");
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// INNGEST_BASE_URL is required
|
|
25
|
-
if (!inngestServerUrl) {
|
|
26
|
-
getLogger().error(
|
|
27
|
-
undefined,
|
|
28
|
-
"INNGEST_BASE_URL environment variable is not set, skipping sync",
|
|
29
|
-
);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const gqlEndpoint = `${inngestServerUrl}/v0/gql`;
|
|
34
|
-
|
|
35
|
-
getLogger().info(
|
|
36
|
-
{ safe: { gqlEndpoint, appUrl } },
|
|
37
|
-
"Syncing with Inngest Server",
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
const payload = {
|
|
41
|
-
operationName: "CreateApp",
|
|
42
|
-
query:
|
|
43
|
-
"mutation CreateApp($input: CreateAppInput!) {\n createApp(input: $input) {\n url\n }\n}\n",
|
|
44
|
-
variables: {
|
|
45
|
-
input: {
|
|
46
|
-
url: appUrl,
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const response = await fetch(gqlEndpoint, {
|
|
52
|
-
method: "POST",
|
|
53
|
-
headers: {
|
|
54
|
-
"Content-Type": "application/json",
|
|
55
|
-
},
|
|
56
|
-
body: JSON.stringify(payload),
|
|
6
|
+
await syncPerceptaInngestApp({
|
|
7
|
+
getEnv: getEnvConfig,
|
|
8
|
+
logger: getLogger(),
|
|
57
9
|
});
|
|
58
|
-
|
|
59
|
-
if (response.ok) {
|
|
60
|
-
getLogger().info(undefined, "Successfully synced with Inngest Server");
|
|
61
|
-
}
|
|
62
10
|
}
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
2
|
-
import { createOpenAI } from "@ai-sdk/openai";
|
|
3
|
-
import { type LanguageModel } from "ai";
|
|
4
|
-
import { getEnvConfig } from "../../config/getEnvConfig";
|
|
5
|
-
|
|
6
|
-
export type LlmProviderName = "anthropic" | "openai";
|
|
7
|
-
|
|
8
|
-
export interface LanguageModelSelection {
|
|
9
|
-
model: LanguageModel;
|
|
10
|
-
modelId: string;
|
|
11
|
-
provider: LlmProviderName;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface GetLanguageModelOptions {
|
|
15
|
-
modelId?: string;
|
|
16
|
-
provider?: LlmProviderName;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5-20250929";
|
|
20
|
-
const DEFAULT_OPENAI_MODEL = "gpt-4.1";
|
|
21
|
-
|
|
22
|
-
export class LlmProviderService {
|
|
23
|
-
private static SINGLETON: LlmProviderService | undefined;
|
|
24
|
-
|
|
25
|
-
public static create(): LlmProviderService {
|
|
26
|
-
if (LlmProviderService.SINGLETON == null) {
|
|
27
|
-
LlmProviderService.SINGLETON = new LlmProviderService();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return LlmProviderService.SINGLETON;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
private constructor() {}
|
|
34
|
-
|
|
35
|
-
public getLanguageModel(
|
|
36
|
-
options: GetLanguageModelOptions = {},
|
|
37
|
-
): LanguageModelSelection {
|
|
38
|
-
const config = getEnvConfig();
|
|
39
|
-
const provider =
|
|
40
|
-
options.provider ??
|
|
41
|
-
config.LLM_PROVIDER ??
|
|
42
|
-
(config.ANTHROPIC_API_KEY ? "anthropic" : undefined) ??
|
|
43
|
-
(config.OPENAI_API_KEY ? "openai" : undefined);
|
|
44
|
-
|
|
45
|
-
if (provider === "anthropic") {
|
|
46
|
-
if (!config.ANTHROPIC_API_KEY) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
"LLM_PROVIDER=anthropic but ANTHROPIC_API_KEY is not configured.",
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const modelId =
|
|
53
|
-
options.modelId ?? config.LLM_MODEL ?? DEFAULT_ANTHROPIC_MODEL;
|
|
54
|
-
return {
|
|
55
|
-
provider,
|
|
56
|
-
modelId,
|
|
57
|
-
model: createAnthropic({
|
|
58
|
-
apiKey: config.ANTHROPIC_API_KEY,
|
|
59
|
-
}).languageModel(modelId),
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (provider === "openai") {
|
|
64
|
-
if (!config.OPENAI_API_KEY) {
|
|
65
|
-
throw new Error(
|
|
66
|
-
"LLM_PROVIDER=openai but OPENAI_API_KEY is not configured.",
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const modelId =
|
|
71
|
-
options.modelId ?? config.LLM_MODEL ?? DEFAULT_OPENAI_MODEL;
|
|
72
|
-
return {
|
|
73
|
-
provider,
|
|
74
|
-
modelId,
|
|
75
|
-
model: createOpenAI({
|
|
76
|
-
apiKey: config.OPENAI_API_KEY,
|
|
77
|
-
}).languageModel(modelId),
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
throw new Error(
|
|
82
|
-
"No LLM provider is configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY locally, or deploy to an environment with a shared LLM variable group.",
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
}
|