Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c32b797b3 | ||
|
|
76b019cdfe | ||
|
|
cd3d02b75f | ||
|
|
10d6637ea0 | ||
|
|
7d9a396bcf | ||
|
|
fb3dcf8d60 | ||
|
|
52fa068d90 | ||
|
|
e51b3ebcd0 | ||
|
|
eaeaef0c3a | ||
|
|
4a5fe63c34 | ||
|
|
4e8f0221fc | ||
|
|
bc9ef188b4 | ||
|
|
e3c741d3b5 | ||
|
|
97342f5830 | ||
|
|
6508550cc7 | ||
|
|
2012bd14ee | ||
|
|
ecb0d443ad | ||
|
|
585b50ae76 | ||
|
|
439e9dbbbd | ||
|
|
9e59c2c2a7 | ||
|
|
82a4ac4926 | ||
|
|
d205b39edf | ||
|
|
c735ea914a | ||
|
|
4c2be93bd1 | ||
|
|
ac393ee675 | ||
|
|
020c344211 | ||
|
|
e5074f2ead | ||
|
|
8226dd89dc | ||
|
|
96fed782c6 | ||
|
|
77ef07bc7f | ||
|
|
00784f72c5 | ||
|
|
fdc3d2f1f8 | ||
|
|
253a4f8cd0 | ||
|
|
c3438490e7 | ||
|
|
f993eb1efc | ||
|
|
210b6bfe2a | ||
|
|
e488990476 | ||
|
|
a7ecf440f7 | ||
|
|
9d62604937 | ||
|
|
8834a3af42 | ||
|
|
533d0f0029 | ||
|
|
cd9ae6d0c3 | ||
|
|
88313c1d82 | ||
|
|
b5bada7b21 | ||
|
|
b3e2e2dfd6 | ||
|
|
5a7420ec2a | ||
|
|
5abbf2c5a3 | ||
|
|
06ee77746a | ||
|
|
d8bab6b29f | ||
|
|
f3ad6ae999 | ||
|
|
011ce23d7f | ||
|
|
b8b5c6012a | ||
|
|
53a0e9d074 | ||
|
|
9dacef8c25 | ||
|
|
29026c361f | ||
|
|
3e1b5992d4 | ||
|
|
2c4a486edd | ||
|
|
ade2c3cff2 | ||
|
|
3089778d7d | ||
|
|
7221ba1018 | ||
|
|
a851d49c5e | ||
|
|
bcf993a74d | ||
|
|
7b1ddc6182 | ||
|
|
628156a39e | ||
|
|
59d0e620f0 | ||
|
|
23f1a51b2e | ||
|
|
285f980b56 | ||
|
|
df4d782182 | ||
|
|
571005d2f3 | ||
|
|
6fc9ea6bad | ||
|
|
d4aa05a4d2 | ||
|
|
aecb6ef2be | ||
|
|
824758349d | ||
|
|
2839f659bb | ||
|
|
8dd7b23a3e | ||
|
|
3e61ecc84f | ||
|
|
692789d0f5 | ||
|
|
864055f593 | ||
|
|
f741f84e97 | ||
|
|
c2ff2c8b7d | ||
|
|
02abecaaeb | ||
|
|
5d99c5a4a9 | ||
|
|
aa76b348e8 | ||
|
|
56c27d4a63 | ||
|
|
7c150e7f77 | ||
|
|
4ff398eda0 | ||
|
|
471e150a87 | ||
|
|
01df1ede47 | ||
|
|
0debc7274e | ||
|
|
e91a046d40 | ||
|
|
818fd3b16e | ||
|
|
d65ee4f6cb | ||
|
|
7d15ee9ed4 | ||
|
|
29faeb31e5 | ||
|
|
e7892863c4 | ||
|
|
c8faf987a7 | ||
|
|
de75917221 | ||
|
|
9440dc24c1 | ||
|
|
ce7297a42f | ||
|
|
35e837700e | ||
|
|
41c529ddc0 | ||
|
|
c091da7c3d | ||
|
|
f72184cc01 | ||
|
|
ee7aca767e | ||
|
|
073cd30cc1 | ||
|
|
fe76bd280f | ||
|
|
eb7ee42b8b | ||
|
|
d3d16272ed | ||
|
|
42f7361090 | ||
|
|
dd083cc867 | ||
|
|
9c6e749c97 | ||
|
|
ff97a3b413 | ||
|
|
1bd069f24f | ||
|
|
52dcb23953 | ||
|
|
2abaa0438e | ||
|
|
1e6d0a70a0 | ||
|
|
e91239e3ac | ||
|
|
418a2bf308 | ||
|
|
d08ce5bb71 | ||
|
|
baab82adae | ||
|
|
932bd18df8 |
+4
-1
@@ -40,6 +40,9 @@ evaluating, and debugging AI applications.
|
||||
[`skills/clickhouse-best-practices/SKILL.md`](skills/clickhouse-best-practices/SKILL.md)
|
||||
- Monorepo/Turbo task graph changes:
|
||||
[`skills/turborepo/SKILL.md`](skills/turborepo/SKILL.md)
|
||||
- pnpm dependency upgrades, package-version bumps, or `minimumReleaseAgeExclude`
|
||||
decisions in `pnpm-workspace.yaml`:
|
||||
[`skills/pnpm-upgrade-package/SKILL.md`](skills/pnpm-upgrade-package/SKILL.md)
|
||||
- User-visible frontend changes, Playwright review, or browser signoff:
|
||||
[`skills/frontend-browser-review/SKILL.md`](skills/frontend-browser-review/SKILL.md)
|
||||
- Web UI and frontend entry points:
|
||||
@@ -121,7 +124,7 @@ Minimum verification matrix:
|
||||
- `*/dist/*`
|
||||
- `packages/shared/prisma/generated/*`
|
||||
- Public API contract changes must update Fern sources in `fern/apis/**` and
|
||||
regenerated outputs; never hand-edit `generated/**`.
|
||||
regenerated outputs. Never hand-edit `generated/**`.
|
||||
- Keep tests independent and parallel-safe.
|
||||
- For bug fixes, write the failing test first, confirm it fails, then fix the
|
||||
bug.
|
||||
|
||||
@@ -81,6 +81,17 @@ Use for:
|
||||
|
||||
Open: [changelog-writing/SKILL.md](changelog-writing/SKILL.md)
|
||||
|
||||
### pnpm-upgrade-package
|
||||
|
||||
Use for:
|
||||
- pnpm dependency bumps that need a specific target version
|
||||
- interactive upgrades where the package name or version may be missing
|
||||
- checking whether `pnpm-workspace.yaml` `minimumReleaseAgeExclude` must change
|
||||
- comparing registry latest with the latest version installable under the
|
||||
current release-age gate
|
||||
|
||||
Open: [pnpm-upgrade-package/SKILL.md](pnpm-upgrade-package/SKILL.md)
|
||||
|
||||
## Adding a New Shared Skill
|
||||
|
||||
1. Codex may create or refine shared skills under `.agents/skills/` when a
|
||||
|
||||
@@ -130,8 +130,8 @@ web/src/
|
||||
│ │ ├── public/ # Public REST APIs
|
||||
│ │ └── trpc/ # tRPC endpoint
|
||||
│ └── [routes].tsx # Next.js pages
|
||||
├── __tests__/ # Jest tests
|
||||
│ └── async/ # Integration tests
|
||||
├── __tests__/ # Vitest tests
|
||||
│ └── server/ # Integration tests
|
||||
├── instrumentation.ts # OpenTelemetry (FIRST IMPORT)
|
||||
└── env.mjs # Environment config
|
||||
```
|
||||
@@ -359,12 +359,12 @@ Write tests for all new features and bug fixes. See [testing-guide.md](reference
|
||||
|
||||
**Test Types:**
|
||||
|
||||
| Type | Framework | Location | Purpose |
|
||||
| ----------- | --------- | --------------------------------------- | ---------------------------- |
|
||||
| Integration | Jest | `web/src/__tests__/async/` | Full API endpoint testing |
|
||||
| tRPC | Jest | `web/src/__tests__/async/` | tRPC procedures with auth |
|
||||
| Service | Jest | `web/src/__tests__/async/repositories/` | Repository/service functions |
|
||||
| Worker | Vitest | `worker/src/__tests__/` | Queue processors & streams |
|
||||
| Type | Framework | Location | Purpose |
|
||||
| ----------- | --------- | ------------------------------------------ | ---------------------------- |
|
||||
| Integration | Vitest | `web/src/__tests__/server/` | Full API endpoint testing |
|
||||
| tRPC | Vitest | `web/src/__tests__/server/` | tRPC procedures with auth |
|
||||
| Service | Vitest | `web/src/__tests__/server/repositories/` | Repository/service functions |
|
||||
| Worker | Vitest | `worker/src/__tests__/` | Queue processors & streams |
|
||||
|
||||
**Quick Examples:**
|
||||
|
||||
@@ -570,7 +570,7 @@ Environment variable validation with Zod, package-specific configs (web/env.mjs
|
||||
|
||||
### [testing-guide.md](references/testing-guide.md)
|
||||
|
||||
Integration tests (Public API with makeZodVerifiedAPICall), tRPC tests (createInnerTRPCContext, appRouter.createCaller), service-level tests (repository/service functions), worker tests (vitest with streams), test isolation principles, running tests (Jest for web, vitest for worker)
|
||||
Integration tests (Public API with makeZodVerifiedAPICall), tRPC tests (createInnerTRPCContext, appRouter.createCaller), service-level tests (repository/service functions), worker tests (vitest with streams), test isolation principles, running tests (Vitest for web and worker)
|
||||
|
||||
**Skill Status**: COMPLETE ✅
|
||||
**Line Count**: ~540 lines
|
||||
|
||||
@@ -810,16 +810,17 @@ class UserService {
|
||||
import { UserService } from "../services/userService";
|
||||
import { userRepository } from "../repositories/UserRepository";
|
||||
import { ConflictError } from "../utils/errors";
|
||||
import type { Mock } from "vitest";
|
||||
|
||||
// Mock repository
|
||||
jest.mock("../repositories/UserRepository");
|
||||
vi.mock("../repositories/UserRepository");
|
||||
|
||||
describe("UserService", () => {
|
||||
let userService: UserService;
|
||||
|
||||
beforeEach(() => {
|
||||
userService = new UserService();
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createUser", () => {
|
||||
@@ -831,8 +832,8 @@ describe("UserService", () => {
|
||||
roles: ["user"],
|
||||
};
|
||||
|
||||
(userRepository.emailExists as jest.Mock).mockResolvedValue(false);
|
||||
(userRepository.create as jest.Mock).mockResolvedValue({
|
||||
(userRepository.emailExists as Mock).mockResolvedValue(false);
|
||||
(userRepository.create as Mock).mockResolvedValue({
|
||||
userID: "123",
|
||||
...userData,
|
||||
});
|
||||
@@ -855,7 +856,7 @@ describe("UserService", () => {
|
||||
roles: ["user"],
|
||||
};
|
||||
|
||||
(userRepository.emailExists as jest.Mock).mockResolvedValue(true);
|
||||
(userRepository.emailExists as Mock).mockResolvedValue(true);
|
||||
|
||||
// Act & Assert
|
||||
await expect(userService.createUser(userData)).rejects.toThrow(
|
||||
|
||||
@@ -20,9 +20,9 @@ Langfuse uses multiple testing strategies for different layers:
|
||||
|
||||
| Test Type | Framework | Location | Purpose |
|
||||
|-----------|-----------|----------|---------|
|
||||
| Integration | Jest | `web/src/__tests__/async/` | Full API endpoint testing |
|
||||
| tRPC | Jest | `web/src/__tests__/async/` | tRPC procedure testing with auth |
|
||||
| Service | Jest | `web/src/__tests__/async/repositories/` | Repository/service function testing |
|
||||
| Integration | Vitest | `web/src/__tests__/server/` | Full API endpoint testing |
|
||||
| tRPC | Vitest | `web/src/__tests__/server/` | tRPC procedure testing with auth |
|
||||
| Service | Vitest | `web/src/__tests__/server/repositories/` | Repository/service function testing |
|
||||
| Worker | Vitest | `worker/src/__tests__/` | Queue processors and streams |
|
||||
|
||||
---
|
||||
@@ -31,7 +31,7 @@ Langfuse uses multiple testing strategies for different layers:
|
||||
|
||||
Test full REST API endpoints end-to-end using HTTP requests.
|
||||
|
||||
**File location:** `web/src/__tests__/async/datasets-api.servertest.ts`
|
||||
**File location:** `web/src/__tests__/server/datasets-api.servertest.ts`
|
||||
|
||||
```typescript
|
||||
import { makeZodVerifiedAPICall } from "../helpers";
|
||||
@@ -73,7 +73,7 @@ describe("Dataset API", () => {
|
||||
|
||||
Test individual repository/service functions with isolated data.
|
||||
|
||||
**File location:** `web/src/__tests__/async/repositories/event-repository.servertest.ts`
|
||||
**File location:** `web/src/__tests__/server/repositories/event-repository.servertest.ts`
|
||||
|
||||
```typescript
|
||||
import {
|
||||
@@ -186,7 +186,7 @@ describe("Event Repository Tests", () => {
|
||||
|
||||
Test tRPC procedures with caller pattern and auth context.
|
||||
|
||||
**File location:** `web/src/__tests__/async/automations-trpc.servertest.ts`
|
||||
**File location:** `web/src/__tests__/server/automations-trpc.servertest.ts`
|
||||
|
||||
```typescript
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
@@ -505,23 +505,20 @@ const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a";
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Web Tests (Jest)
|
||||
### Web Tests (Vitest)
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
# Run all server tests
|
||||
pnpm --filter web run test
|
||||
|
||||
# Run sync tests
|
||||
pnpm test-sync
|
||||
|
||||
# Run async tests
|
||||
pnpm test -- --testPathPattern="async"
|
||||
# Run all client tests
|
||||
pnpm --filter web run test-client
|
||||
|
||||
# Run specific test file
|
||||
pnpm test -- --testPathPattern="datasets-api"
|
||||
pnpm --filter web run test -- datasets-api
|
||||
|
||||
# Run specific test
|
||||
pnpm test -- --testPathPattern="datasets-api" --testNamePattern="should create dataset"
|
||||
# Run watch mode
|
||||
pnpm --filter web run test:watch
|
||||
```
|
||||
|
||||
### Worker Tests (Vitest)
|
||||
@@ -537,16 +534,6 @@ pnpm run test --filter=worker -- batchExport
|
||||
pnpm run test --filter=worker -- batchExport -t "should export observations"
|
||||
```
|
||||
|
||||
### Coverage
|
||||
|
||||
```bash
|
||||
# Web coverage
|
||||
pnpm test -- --coverage
|
||||
|
||||
# Worker coverage
|
||||
pnpm run test --filter=worker -- --coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Related Files:**
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# PNPM Upgrade Package
|
||||
|
||||
Use this workflow when a user wants to upgrade a dependency in the Langfuse
|
||||
pnpm workspace.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Collect missing inputs.
|
||||
- Ask for the package if missing.
|
||||
- Ask for the target version if missing.
|
||||
- If the user says `latest`, resolve the real registry latest first.
|
||||
|
||||
2. Run the main helper once.
|
||||
- Run
|
||||
`node .agents/skills/pnpm-upgrade-package/scripts/check-release-age-window.mjs <package> <targetVersion>`.
|
||||
- Treat this as the single source of truth for:
|
||||
- direct workspace references
|
||||
- root `pnpm.overrides` / `pnpm.patchedDependencies`
|
||||
- latest registry version
|
||||
- latest version installable under the current release-age rules
|
||||
- existing matching `minimumReleaseAgeExclude` entries
|
||||
- exact dependency companions from `dependencies` and `optionalDependencies`
|
||||
- exact peer dependencies that are actually installed in the workspace
|
||||
|
||||
3. Handle the transitive-only case before editing anything.
|
||||
- If the helper shows no direct workspace references, run `pnpm why -r <package>`.
|
||||
- Identify the current top-level parent that pulls the package in.
|
||||
- Check whether that parent's current dependency range already permits the
|
||||
requested transitive version.
|
||||
- If the current parent range already covers the requested version, prefer a
|
||||
lock refresh / reinstall path before changing `package.json`.
|
||||
- If the current parent range does not cover the requested version, upgrade
|
||||
the direct parent dependency that pulls the package in.
|
||||
- If a compatible transitive package still stays pinned after the normal
|
||||
refresh path, you may suggest `pnpm dedupe` to the user as an optional
|
||||
manual follow-up, but do not run it automatically and do not require it.
|
||||
- Do not add the transitive package directly unless the user explicitly asks.
|
||||
|
||||
4. Ask before changing `minimumReleaseAgeExclude`.
|
||||
- Prefer `package@version` entries.
|
||||
- Only use bare `package` entries after explicit approval.
|
||||
- Ask about exact companion packages only when the helper says they still
|
||||
need a new exclusion.
|
||||
- Treat range-based dependency or peer entries as manual review.
|
||||
|
||||
5. Bump at the narrowest useful scope.
|
||||
- `pnpm -w up <package>@<version>` for root-only changes.
|
||||
- `pnpm --filter <workspace> up <package>@<version>` for one workspace.
|
||||
- `pnpm -r up <package>@<version>` only when every current reference should move.
|
||||
- Do not hand-edit `pnpm-lock.yaml`.
|
||||
|
||||
6. Validate.
|
||||
- Use the nearest package `AGENTS.md` plus the root verification matrix.
|
||||
- Finish with `pnpm why -r <package>`.
|
||||
- If companions moved too, run `pnpm why -r <companion-package>` for them as well.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
- Run the single analysis pass:
|
||||
`node .agents/skills/pnpm-upgrade-package/scripts/check-release-age-window.mjs <package> <targetVersion>`
|
||||
- Transitive provenance check:
|
||||
`pnpm why -r <package>`
|
||||
- Inspect the current parent manifest on the registry:
|
||||
`npm view <parent>@<installedVersion> dependencies peerDependencies optionalDependencies --json`
|
||||
- Final graph verification:
|
||||
`pnpm why -r <package>`
|
||||
- Bump in the root workspace:
|
||||
`pnpm -w up <package>@<version>`
|
||||
- Bump in one workspace:
|
||||
`pnpm --filter web up <package>@<version>`
|
||||
- Bump everywhere that should move together:
|
||||
`pnpm -r up <package>@<version>`
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: pnpm-upgrade-package
|
||||
description: Use when upgrading a dependency in this pnpm workspace, including requests to bump a package to a specific version, compare the registry latest version with the latest version installable under the current minimum-release-age window, or decide whether minimumReleaseAgeExclude in pnpm-workspace.yaml must change. Ask the user for the package name or target version when either is missing.
|
||||
---
|
||||
|
||||
# PNPM Upgrade Package
|
||||
|
||||
Use this skill for interactive dependency bumps in Langfuse.
|
||||
|
||||
## Read Order
|
||||
|
||||
- Start with [AGENTS.md](AGENTS.md) for the end-to-end workflow.
|
||||
- Run the main helper once at the start of the upgrade:
|
||||
`node .agents/skills/pnpm-upgrade-package/scripts/check-release-age-window.mjs <package> [targetVersion]`
|
||||
|
||||
## Apply This Skill
|
||||
|
||||
- Ask for the package name if the user did not provide one.
|
||||
- Ask for the target version if the user did not provide one.
|
||||
- Run the main helper once as the first analysis step and use that single
|
||||
output for scope, exclusion decisions, and the final bump.
|
||||
- If the target package is not directly declared anywhere, run
|
||||
`pnpm why -r <package>` to find which direct dependency brings it in, then
|
||||
inspect whether the current top-level parent already allows the requested
|
||||
transitive version via its dependency range.
|
||||
- If the current parent range already covers the requested transitive version,
|
||||
prefer a lockfile refresh / reinstall path over bumping the parent manifest.
|
||||
- If the current parent range does not cover the requested transitive version,
|
||||
upgrade that parent dependency instead of adding the target package directly
|
||||
unless the user explicitly wants that.
|
||||
- Never manually edit `pnpm-lock.yaml`; regenerate lockfile changes with
|
||||
`pnpm` commands only. If a lockfile-only refresh causes unrelated churn,
|
||||
adjust the pnpm command and rerun instead of patching the lockfile by hand.
|
||||
- If a compatible transitive package still stays pinned after the normal
|
||||
refresh path, you may suggest `pnpm dedupe` to the user as an optional manual
|
||||
follow-up, but do not run it automatically and do not require it.
|
||||
- Resolve the registry latest version, but do not silently upgrade to latest
|
||||
unless the user asked for latest.
|
||||
- Compare the target version with the latest version installable under the
|
||||
current `minimumReleaseAge` window.
|
||||
- Ask before adding `minimumReleaseAgeExclude` entries for the target package,
|
||||
exact dependency companions from `dependencies` or `optionalDependencies`, or
|
||||
locally installed exact peer dependencies.
|
||||
- Finish with `pnpm why -r <package>` to confirm that only the intended version
|
||||
remains in the workspace.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "PNPM Upgrade Package"
|
||||
short_description: "Interactive pnpm package bump workflow"
|
||||
default_prompt: "Use $pnpm-upgrade-package to upgrade a package in this pnpm workspace, asking me for the package or version if I did not provide them."
|
||||
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
entryCoversVersion,
|
||||
findLocalPackageReferences,
|
||||
formatWorkspaceReference,
|
||||
getRootPnpmControls,
|
||||
readWorkspaceConfig,
|
||||
} from "./lib/workspace-utils.mjs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const asJson = args.includes("--json");
|
||||
const positional = args.filter((arg) => !arg.startsWith("--"));
|
||||
const packageName = positional[0];
|
||||
const requestedTargetVersion = positional[1] ?? null;
|
||||
|
||||
if (!packageName) {
|
||||
console.error(
|
||||
"Usage: node .agents/skills/pnpm-upgrade-package/scripts/check-release-age-window.mjs <package> [targetVersion] [--json]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const workspaceConfig = readWorkspaceConfig(join(repoRoot, "pnpm-workspace.yaml"));
|
||||
const minimumReleaseAgeMinutes = workspaceConfig.minimumReleaseAge ?? 0;
|
||||
const thresholdMs = Date.now() - minimumReleaseAgeMinutes * 60 * 1000;
|
||||
const REGISTRY_FETCH_TIMEOUT_MS = 30_000;
|
||||
const registryCache = new Map();
|
||||
const workspaceReferenceCache = new Map();
|
||||
|
||||
const getWorkspaceReferences = (name) => {
|
||||
if (!workspaceReferenceCache.has(name)) {
|
||||
workspaceReferenceCache.set(name, findLocalPackageReferences(repoRoot, name));
|
||||
}
|
||||
|
||||
return workspaceReferenceCache.get(name);
|
||||
};
|
||||
|
||||
function printSectionHeader(title) {
|
||||
console.log("");
|
||||
console.log(title);
|
||||
}
|
||||
|
||||
function isPrerelease(version) {
|
||||
return version.includes("-");
|
||||
}
|
||||
|
||||
function isExactVersion(spec) {
|
||||
return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(spec.trim());
|
||||
}
|
||||
|
||||
function getMatchingExcludeEntries(name, version) {
|
||||
return workspaceConfig.minimumReleaseAgeExclude.filter((entry) =>
|
||||
entryCoversVersion(entry, name, version),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchRegistryPackage(name) {
|
||||
if (registryCache.has(name)) return registryCache.get(name);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => abortController.abort(),
|
||||
REGISTRY_FETCH_TIMEOUT_MS,
|
||||
);
|
||||
timeoutId.unref?.();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://registry.npmjs.org/${encodeURIComponent(name)}`,
|
||||
{
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"user-agent": "langfuse-pnpm-upgrade-package-skill",
|
||||
},
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch ${name} from npm registry: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = await response.json();
|
||||
registryCache.set(name, metadata);
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
throw new Error(
|
||||
`Timed out fetching ${name} from npm registry after ${REGISTRY_FETCH_TIMEOUT_MS}ms`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function getInstallability(metadata, name, version) {
|
||||
const publishedAt = metadata.time?.[version] ?? null;
|
||||
const publishedAtMs = publishedAt ? Date.parse(publishedAt) : null;
|
||||
const matchingExcludeEntries = getMatchingExcludeEntries(name, version);
|
||||
const isYoungerThanMinimumReleaseAge =
|
||||
publishedAtMs != null ? publishedAtMs > thresholdMs : null;
|
||||
const isInstallableWithoutNewExclude =
|
||||
isYoungerThanMinimumReleaseAge == null
|
||||
? null
|
||||
: !isYoungerThanMinimumReleaseAge || matchingExcludeEntries.length > 0;
|
||||
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
publishedAt,
|
||||
isYoungerThanMinimumReleaseAge,
|
||||
isInstallableWithoutNewExclude,
|
||||
matchingExcludeEntries,
|
||||
suggestedExclude:
|
||||
isInstallableWithoutNewExclude === false ? `${name}@${version}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectLatestInstallableVersion(metadata, name) {
|
||||
const times = metadata.time ?? {};
|
||||
|
||||
return (
|
||||
Object.keys(metadata.versions ?? {})
|
||||
.filter((version) => times[version] && !isPrerelease(version))
|
||||
.sort((left, right) => Date.parse(times[right]) - Date.parse(times[left]))
|
||||
.map((version) => getInstallability(metadata, name, version))
|
||||
.find((candidate) => candidate.isInstallableWithoutNewExclude) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function collectManifestEntries(manifest, fields) {
|
||||
const merged = new Map();
|
||||
|
||||
for (const field of fields) {
|
||||
for (const [name, spec] of Object.entries(manifest[field] ?? {})) {
|
||||
const key = `${name}:${spec}`;
|
||||
const entry = merged.get(key);
|
||||
|
||||
if (entry) {
|
||||
entry.fields.push(field);
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.set(key, { name, spec, fields: [field] });
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.values()].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
}
|
||||
|
||||
async function analyzeManifestEntries(entries, { includeWorkspace = false } = {}) {
|
||||
const exact = [];
|
||||
const range = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const workspaceReferences = includeWorkspace
|
||||
? getWorkspaceReferences(entry.name)
|
||||
: null;
|
||||
|
||||
if (!isExactVersion(entry.spec)) {
|
||||
range.push({
|
||||
...entry,
|
||||
...(includeWorkspace ? { workspaceReferences } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = await fetchRegistryPackage(entry.name);
|
||||
const installability = getInstallability(metadata, entry.name, entry.spec);
|
||||
|
||||
exact.push({
|
||||
...entry,
|
||||
...installability,
|
||||
...(includeWorkspace
|
||||
? {
|
||||
workspaceReferences,
|
||||
isInstalledInWorkspace: workspaceReferences.length > 0,
|
||||
}
|
||||
: {}),
|
||||
suggestedExclude:
|
||||
includeWorkspace && workspaceReferences.length === 0
|
||||
? null
|
||||
: installability.suggestedExclude,
|
||||
});
|
||||
}
|
||||
|
||||
return { exact, range };
|
||||
}
|
||||
|
||||
function printWorkspaceReferences(title, references) {
|
||||
printSectionHeader(title);
|
||||
if (references.length === 0) {
|
||||
console.log("- none");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const reference of references) {
|
||||
console.log(`- ${formatWorkspaceReference(reference)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printRootPnpmControls(rootPnpm) {
|
||||
printSectionHeader("Root pnpm controls:");
|
||||
if (
|
||||
rootPnpm.overrideMatches.length === 0 &&
|
||||
rootPnpm.patchedDependencyMatches.length === 0
|
||||
) {
|
||||
console.log("- none");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of rootPnpm.overrideMatches) {
|
||||
console.log(`- override ${match.selector}: ${match.value}`);
|
||||
}
|
||||
for (const match of rootPnpm.patchedDependencyMatches) {
|
||||
console.log(`- patched dependency ${match.selector}: ${match.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printVersionEntries(title, entries, { includeWorkspace = false } = {}) {
|
||||
printSectionHeader(title);
|
||||
if (entries.length === 0) {
|
||||
console.log("- none");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const status =
|
||||
entry.isInstallableWithoutNewExclude == null
|
||||
? "unknown"
|
||||
: entry.isInstallableWithoutNewExclude
|
||||
? "installable now"
|
||||
: "needs exclude";
|
||||
|
||||
console.log(
|
||||
`- ${entry.name}@${entry.version} (${status}; via ${entry.fields.join(", ")})`,
|
||||
);
|
||||
if (entry.publishedAt) {
|
||||
console.log(` published at: ${entry.publishedAt}`);
|
||||
}
|
||||
if (includeWorkspace) {
|
||||
console.log(
|
||||
` installed in workspace: ${entry.isInstalledInWorkspace ? "yes" : "no"}`,
|
||||
);
|
||||
for (const reference of entry.workspaceReferences) {
|
||||
console.log(` workspace reference: ${formatWorkspaceReference(reference)}`);
|
||||
}
|
||||
}
|
||||
if (entry.matchingExcludeEntries.length > 0) {
|
||||
console.log(
|
||||
` matching exclude entries: ${entry.matchingExcludeEntries.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (entry.suggestedExclude) {
|
||||
console.log(` suggested exclude: ${entry.suggestedExclude}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printRangeEntries(title, entries) {
|
||||
printSectionHeader(title);
|
||||
if (entries.length === 0) {
|
||||
console.log("- none");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
console.log(
|
||||
`- ${entry.name}: ${entry.spec} (manual review; via ${entry.fields.join(", ")})`,
|
||||
);
|
||||
for (const reference of entry.workspaceReferences ?? []) {
|
||||
console.log(` workspace reference: ${formatWorkspaceReference(reference)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const packageMetadata = await fetchRegistryPackage(packageName);
|
||||
const latestVersion = packageMetadata["dist-tags"]?.latest ?? null;
|
||||
const targetVersion = requestedTargetVersion ?? latestVersion;
|
||||
|
||||
if (!targetVersion) {
|
||||
console.error(`Could not resolve a target version for ${packageName}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!packageMetadata.versions?.[targetVersion]) {
|
||||
console.error(`Version ${targetVersion} was not found for ${packageName}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const packageWorkspaceReferences = getWorkspaceReferences(packageName);
|
||||
const rootPnpm = getRootPnpmControls(repoRoot, packageName);
|
||||
const latestInstallableWithoutNewExclude = selectLatestInstallableVersion(
|
||||
packageMetadata,
|
||||
packageName,
|
||||
);
|
||||
const targetInstallability = getInstallability(
|
||||
packageMetadata,
|
||||
packageName,
|
||||
targetVersion,
|
||||
);
|
||||
const targetManifest = packageMetadata.versions[targetVersion];
|
||||
|
||||
const dependencyCompanions = await analyzeManifestEntries(
|
||||
collectManifestEntries(targetManifest, [
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
]),
|
||||
);
|
||||
const peerDependencies = await analyzeManifestEntries(
|
||||
collectManifestEntries(targetManifest, ["peerDependencies"]),
|
||||
{ includeWorkspace: true },
|
||||
);
|
||||
|
||||
const result = {
|
||||
packageName,
|
||||
targetVersion,
|
||||
targetWasExplicitlyProvided: requestedTargetVersion != null,
|
||||
packageWorkspaceReferences,
|
||||
rootPnpm,
|
||||
minimumReleaseAgeMinutes,
|
||||
thresholdIso: new Date(thresholdMs).toISOString(),
|
||||
latestRegistryVersion: latestVersion,
|
||||
latestRegistryPublishedAt:
|
||||
latestVersion != null ? packageMetadata.time?.[latestVersion] ?? null : null,
|
||||
latestInstallableWithoutNewExclude,
|
||||
targetPublishedAt: targetInstallability.publishedAt,
|
||||
targetIsYoungerThanMinimumReleaseAge:
|
||||
targetInstallability.isYoungerThanMinimumReleaseAge,
|
||||
targetIsInstallableWithoutNewExclude:
|
||||
targetInstallability.isInstallableWithoutNewExclude,
|
||||
matchingPackageExcludeEntries: targetInstallability.matchingExcludeEntries,
|
||||
suggestedPackageExclude: targetInstallability.suggestedExclude,
|
||||
exactDependencyCompanions: dependencyCompanions.exact,
|
||||
rangeDependencyCompanions: dependencyCompanions.range,
|
||||
exactPeerDependencies: peerDependencies.exact,
|
||||
rangePeerDependencies: peerDependencies.range,
|
||||
};
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Package: ${packageName}`);
|
||||
console.log(
|
||||
`Target version: ${targetVersion}${
|
||||
requestedTargetVersion
|
||||
? ""
|
||||
: " (resolved latest; still ask before bumping if version was omitted)"
|
||||
}`,
|
||||
);
|
||||
|
||||
printWorkspaceReferences(
|
||||
"Target package workspace references:",
|
||||
packageWorkspaceReferences,
|
||||
);
|
||||
printRootPnpmControls(rootPnpm);
|
||||
|
||||
printSectionHeader("Release-age window:");
|
||||
console.log(`minimumReleaseAge: ${minimumReleaseAgeMinutes} minutes`);
|
||||
console.log(`Threshold: ${result.thresholdIso}`);
|
||||
console.log(`Latest registry version: ${result.latestRegistryVersion ?? "unknown"}`);
|
||||
if (result.latestRegistryPublishedAt) {
|
||||
console.log(`Latest registry published at: ${result.latestRegistryPublishedAt}`);
|
||||
}
|
||||
if (latestInstallableWithoutNewExclude) {
|
||||
console.log(
|
||||
`Latest installable without new exclude: ${latestInstallableWithoutNewExclude.version} (${latestInstallableWithoutNewExclude.publishedAt})`,
|
||||
);
|
||||
} else {
|
||||
console.log("Latest installable without new exclude: none found");
|
||||
}
|
||||
console.log(`Target published at: ${result.targetPublishedAt ?? "unknown"}`);
|
||||
console.log(
|
||||
`Target installable without new exclude: ${
|
||||
result.targetIsInstallableWithoutNewExclude == null
|
||||
? "unknown"
|
||||
: result.targetIsInstallableWithoutNewExclude
|
||||
? "yes"
|
||||
: "no"
|
||||
}`,
|
||||
);
|
||||
if (result.matchingPackageExcludeEntries.length > 0) {
|
||||
console.log("Matching package exclude entries:");
|
||||
for (const entry of result.matchingPackageExcludeEntries) {
|
||||
console.log(`- ${entry}`);
|
||||
}
|
||||
} else {
|
||||
console.log("Matching package exclude entries: none");
|
||||
}
|
||||
if (result.suggestedPackageExclude) {
|
||||
console.log(`Suggested package exclude: ${result.suggestedPackageExclude}`);
|
||||
}
|
||||
|
||||
printVersionEntries(
|
||||
"Exact dependency companions (dependencies + optionalDependencies):",
|
||||
result.exactDependencyCompanions,
|
||||
);
|
||||
printRangeEntries(
|
||||
"Range dependency companions (dependencies + optionalDependencies):",
|
||||
result.rangeDependencyCompanions,
|
||||
);
|
||||
printVersionEntries("Exact peer dependencies:", result.exactPeerDependencies, {
|
||||
includeWorkspace: true,
|
||||
});
|
||||
printRangeEntries("Range peer dependencies:", result.rangePeerDependencies);
|
||||
@@ -0,0 +1,208 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const packageFields = [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"peerDependencies",
|
||||
"optionalDependencies",
|
||||
];
|
||||
|
||||
export function formatWorkspaceReference(reference) {
|
||||
const label = reference.workspaceName
|
||||
? `${reference.path} (${reference.workspaceName})`
|
||||
: reference.path;
|
||||
const specs = reference.matches
|
||||
.map((match) => `${match.field}: ${match.spec}`)
|
||||
.join(", ");
|
||||
|
||||
return `${label} -> ${specs}`;
|
||||
}
|
||||
|
||||
export function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function stripInlineComment(line) {
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "#") {
|
||||
return line.slice(0, index).trimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
export function readWorkspaceConfig(path) {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let minimumReleaseAge = 0;
|
||||
const minimumReleaseAgeExclude = [];
|
||||
let inExcludeBlock = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const uncommented = stripInlineComment(line);
|
||||
const trimmed = uncommented.trim();
|
||||
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
const ageMatch = trimmed.match(/^minimumReleaseAge:\s*(\d+)\s*$/);
|
||||
if (ageMatch) {
|
||||
minimumReleaseAge = Number(ageMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^minimumReleaseAgeExclude:\s*$/.test(trimmed)) {
|
||||
inExcludeBlock = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inExcludeBlock) {
|
||||
const excludeMatch = uncommented.match(/^\s*-\s+(.+?)\s*$/);
|
||||
if (excludeMatch) {
|
||||
minimumReleaseAgeExclude.push(
|
||||
excludeMatch[1].replace(/^['"]|['"]$/g, ""),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\S/.test(uncommented)) {
|
||||
inExcludeBlock = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { minimumReleaseAge, minimumReleaseAgeExclude };
|
||||
}
|
||||
|
||||
export function collectPackageJsonPaths(repoRoot) {
|
||||
const paths = [
|
||||
"package.json",
|
||||
"web/package.json",
|
||||
"worker/package.json",
|
||||
"ee/package.json",
|
||||
];
|
||||
const packagesRoot = join(repoRoot, "packages");
|
||||
|
||||
if (!existsSync(packagesRoot)) return paths;
|
||||
|
||||
const stack = [packagesRoot];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.name === "node_modules" ||
|
||||
entry.name === "dist" ||
|
||||
entry.name === ".git"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextPath = join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(nextPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name === "package.json") {
|
||||
paths.push(relative(repoRoot, nextPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
export function matchesPackageSelector(selector, wantedPackage) {
|
||||
if (selector === wantedPackage) return true;
|
||||
if (selector.startsWith(`${wantedPackage}@`)) return true;
|
||||
if (selector.endsWith(`>${wantedPackage}`)) return true;
|
||||
if (selector.includes(`>${wantedPackage}@`)) return true;
|
||||
if (selector.endsWith("/*")) {
|
||||
const prefix = selector.slice(0, -1);
|
||||
return wantedPackage.startsWith(prefix);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function entryCoversVersion(entry, wantedPackage, wantedVersion) {
|
||||
if (entry === wantedPackage) return true;
|
||||
if (entry.endsWith("/*")) {
|
||||
const prefix = entry.slice(0, -1);
|
||||
return wantedPackage.startsWith(prefix);
|
||||
}
|
||||
if (!entry.startsWith(`${wantedPackage}@`)) return false;
|
||||
|
||||
return entry
|
||||
.slice(wantedPackage.length + 1)
|
||||
.split("||")
|
||||
.map((part) => part.trim())
|
||||
.includes(wantedVersion);
|
||||
}
|
||||
|
||||
export function findLocalPackageReferences(repoRoot, wantedPackage) {
|
||||
const results = [];
|
||||
|
||||
for (const packageJsonPath of collectPackageJsonPaths(repoRoot)) {
|
||||
const json = readJson(join(repoRoot, packageJsonPath));
|
||||
const matches = [];
|
||||
|
||||
for (const field of packageFields) {
|
||||
if (json[field]?.[wantedPackage]) {
|
||||
matches.push({ field, spec: json[field][wantedPackage] });
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
results.push({
|
||||
path: packageJsonPath,
|
||||
workspaceName: json.name ?? null,
|
||||
matches,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function getRootPnpmControls(repoRoot, packageName) {
|
||||
const rootPackageJson = readJson(join(repoRoot, "package.json"));
|
||||
|
||||
return {
|
||||
overrideMatches: Object.entries(rootPackageJson.pnpm?.overrides ?? {})
|
||||
.filter(([selector]) => matchesPackageSelector(selector, packageName))
|
||||
.map(([selector, value]) => ({ selector, value })),
|
||||
patchedDependencyMatches: Object.entries(
|
||||
rootPackageJson.pnpm?.patchedDependencies ?? {},
|
||||
)
|
||||
.filter(([selector]) => matchesPackageSelector(selector, packageName))
|
||||
.map(([selector, value]) => ({ selector, value })),
|
||||
};
|
||||
}
|
||||
@@ -94,13 +94,13 @@ cd packages/utils && bun add lodash
|
||||
|
||||
```bash
|
||||
# pnpm
|
||||
pnpm add jest --save-dev --filter=web --filter=@repo/ui
|
||||
pnpm add vitest --save-dev --filter=web --filter=@repo/ui
|
||||
|
||||
# npm
|
||||
npm install jest --save-dev --workspace=web --workspace=@repo/ui
|
||||
npm install vitest --save-dev --workspace=web --workspace=@repo/ui
|
||||
|
||||
# yarn (v2+)
|
||||
yarn workspaces foreach -R --from '{web,@repo/ui}' add jest --dev
|
||||
yarn workspaces foreach -R --from '{web,@repo/ui}' add vitest --dev
|
||||
```
|
||||
|
||||
### Internal Packages
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#####################################################################
|
||||
# .env (template) — OCI Object Storage / S3-compatible configuration
|
||||
#
|
||||
# IMPORTANT SECURITY NOTES (Oracle best practice)
|
||||
# - Prefer OCI-native auth (Instance Principal / Workload Identity / Resource Principal)
|
||||
# over static keys.
|
||||
# - If you must use static keys, store them in a secure secret manager
|
||||
# (e.g., Kubernetes Secret / OCI Vault) and inject at runtime.
|
||||
# - Rotate/revoke any credentials that were previously shared or committed.
|
||||
#####################################################################
|
||||
|
||||
|
||||
#####################################################################
|
||||
# 1) Storage/Auth category (CHOOSE ONE)
|
||||
#
|
||||
# The app can read/write/download to/from an OCI object store for:
|
||||
# - Batch exports (exports/)
|
||||
# - Media uploads (media/)
|
||||
# - Event uploads (events/)
|
||||
#
|
||||
# Pick exactly ONE auth mechanism for OCI-native object storage by setting:
|
||||
# LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE=true
|
||||
# LANGFUSE_OCI_AUTH_TYPE=<one of the values below>
|
||||
#
|
||||
# Supported values:
|
||||
# workload_identity | instance_principal | resource_principal | oci_profile | session_token
|
||||
#####################################################################
|
||||
|
||||
|
||||
#####################################################################
|
||||
# Category A — OCI Object Storage with INSTANCE PRINCIPAL (recommended on OCI Compute)
|
||||
# Use when:
|
||||
# - Running on OCI Compute with IAM set up (dynamic group + policies)
|
||||
#
|
||||
# Set:
|
||||
# LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE=true
|
||||
# LANGFUSE_OCI_AUTH_TYPE=instance_principal
|
||||
#
|
||||
# NOTE: Do NOT set *_ACCESS_KEY_ID / *_SECRET_ACCESS_KEY in this category.
|
||||
#####################################################################
|
||||
|
||||
|
||||
#####################################################################
|
||||
# Category B — OCI Object Storage with WORKLOAD IDENTITY (common on OKE)
|
||||
# Use when:
|
||||
# - Running on OKE with OCI Workload Identity configured
|
||||
#
|
||||
# Set:
|
||||
# LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE=true
|
||||
# LANGFUSE_OCI_AUTH_TYPE=workload_identity
|
||||
#
|
||||
# Optional (only if your environment requires additional CA trust):
|
||||
# NODE_EXTRA_CA_CERTS=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
#
|
||||
# NOTE: Do NOT set *_ACCESS_KEY_ID / *_SECRET_ACCESS_KEY in this category.
|
||||
#####################################################################
|
||||
|
||||
|
||||
#####################################################################
|
||||
# Category C — OCI Object Storage with RESOURCE PRINCIPAL (common for OCI services)
|
||||
# Use when:
|
||||
# - Running inside an OCI service/runtime that injects Resource Principal env vars
|
||||
# (e.g., certain managed services / automation contexts)
|
||||
#
|
||||
# Set:
|
||||
# LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE=true
|
||||
# LANGFUSE_OCI_AUTH_TYPE=resource_principal
|
||||
#
|
||||
# NOTE: Do NOT set *_ACCESS_KEY_ID / *_SECRET_ACCESS_KEY in this category.
|
||||
#####################################################################
|
||||
|
||||
|
||||
#####################################################################
|
||||
# Category D — OCI Object Storage with OCI CONFIG PROFILE (developer local)
|
||||
# Use when:
|
||||
# - You have an OCI config file locally or mounted in the runtime
|
||||
# - You want to use a named profile
|
||||
#
|
||||
# Set:
|
||||
# LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE=true
|
||||
# LANGFUSE_OCI_AUTH_TYPE=oci_profile
|
||||
# OCI_CONFIG_FILE=/path/to/oci/config
|
||||
# OCI_CONFIG_PROFILE=DEFAULT
|
||||
#
|
||||
# NOTE: Avoid adding config files into images; mount/inject securely.
|
||||
#####################################################################
|
||||
|
||||
|
||||
#####################################################################
|
||||
# Category E — OCI Object Storage with SESSION TOKEN (short-lived user auth)
|
||||
# Use when:
|
||||
# - You use OCI CLI session authentication (short-lived token flow)
|
||||
# - USE oci session authenticate
|
||||
# - Appropriate for interactive/dev use; less common for long-running services
|
||||
#
|
||||
# Set:
|
||||
# LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE=true
|
||||
# LANGFUSE_OCI_AUTH_TYPE=session_token
|
||||
# OCI_CONFIG_FILE=/path/to/oci/config
|
||||
# OCI_CONFIG_PROFILE=DEFAULT
|
||||
#####################################################################
|
||||
|
||||
|
||||
#####################################################################
|
||||
# Other possible setup — Non-OCI provider (AWS S3 / GCP / Azure / MinIO / etc.)
|
||||
# Use when:
|
||||
# - Your object storage is NOT OCI Object Storage
|
||||
#
|
||||
# Set:
|
||||
# LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE=false
|
||||
#
|
||||
# Then configure endpoints/regions/credentials for your provider.
|
||||
#####################################################################
|
||||
|
||||
|
||||
|
||||
#####################################################################
|
||||
# 2) Feature: S3 Batch Export
|
||||
#
|
||||
# Required (when enabled):
|
||||
# - *_BUCKET, *_REGION, *_ENDPOINT, *_PREFIX
|
||||
# Optional:
|
||||
# - *_EXTERNAL_ENDPOINT
|
||||
# - *_FORCE_PATH_STYLE=true (needed for many S3-compatible providers like MinIO)
|
||||
#
|
||||
# Credentials:
|
||||
# - Set *_ACCESS_KEY_ID/_SECRET_ACCESS_KEY ONLY for static-key auth
|
||||
# (non-OCI S3-compatible providers)
|
||||
#####################################################################
|
||||
LANGFUSE_S3_BATCH_EXPORT_ENABLED=true
|
||||
LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse-bucket
|
||||
LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
|
||||
|
||||
# OCI example region/endpoint:
|
||||
LANGFUSE_S3_BATCH_EXPORT_REGION=us-chicago-1
|
||||
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=https://objectstorage.us-chicago-1.oraclecloud.com
|
||||
LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT=https://objectstorage.us-chicago-1.oraclecloud.com
|
||||
|
||||
# MinIO / S3-compat setting (safe to keep true for many S3-compatible endpoints)
|
||||
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
|
||||
|
||||
# Static-key auth (non-OCI). Leave blank/commented for OCI-native auth types above.
|
||||
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=__REPLACE_ME__
|
||||
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=__REPLACE_ME__
|
||||
|
||||
|
||||
|
||||
#####################################################################
|
||||
# 3) Feature: S3 Media Upload
|
||||
#####################################################################
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse-bucket
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION=us-chicago-1
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=https://objectstorage.us-chicago-1.oraclecloud.com
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
|
||||
|
||||
# Static-key auth (non-OCI). Leave blank/commented for OCI-native auth types above.
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=__REPLACE_ME__
|
||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=__REPLACE_ME__
|
||||
|
||||
|
||||
|
||||
#####################################################################
|
||||
# 4) Feature: S3 Event Upload (optional)
|
||||
#####################################################################
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse-bucket
|
||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION=us-chicago-1
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=https://objectstorage.us-chicago-1.oraclecloud.com
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
|
||||
|
||||
# Static-key auth (non-OCI). Leave blank/commented for OCI-native auth types above.
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=__REPLACE_ME__
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=__REPLACE_ME__
|
||||
|
||||
|
||||
|
||||
#####################################################################
|
||||
# 5) OCI native auth configuration (used by oci_profile / session_token)
|
||||
#####################################################################
|
||||
# Only required when LANGFUSE_OCI_AUTH_TYPE is: oci_profile OR session_token
|
||||
OCI_CONFIG_FILE=__REPLACE_ME__/config
|
||||
OCI_CONFIG_PROFILE=DEFAULT
|
||||
|
||||
|
||||
|
||||
#####################################################################
|
||||
# 6) Troubleshooting notes (comments only)
|
||||
#
|
||||
# - If you see TLS errors to the endpoint in Kubernetes/OKE, set NODE_EXTRA_CA_CERTS to the
|
||||
# correct CA bundle path for your environment.
|
||||
# - If using MinIO or certain S3-compatible providers and you get bucket addressing errors,
|
||||
# set *_FORCE_PATH_STYLE=true.
|
||||
# - If downloads work inside the cluster but not externally, configure
|
||||
# LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT to a publicly reachable endpoint/DNS.
|
||||
#####################################################################
|
||||
@@ -153,6 +153,7 @@ LANGFUSE_AI_FEATURES_PROJECT_ID=7a88fb47-b4e2-43b8-a06c-a5ce950dc53a
|
||||
# Langfuse AI Bedrock credentials
|
||||
AWS_ACCESS_KEY_ID="A123456789"
|
||||
AWS_SECRET_ACCESS_KEY="SAK123456789"
|
||||
LANGFUSE_LLM_CONNECTION_BEDROCK_API_KEY="1234567890abcdef"
|
||||
LANGFUSE_AWS_BEDROCK_REGION="eu-west-1"
|
||||
LANGFUSE_AWS_BEDROCK_MODEL="eu.anthropic.claude-3-haiku-20240307-v1:0"
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# AUTH_GOOGLE_ALLOWED_DOMAINS=langfuse.com,google.com # optional allowlist of workspace domains that can sign in via Google
|
||||
# AUTH_GOOGLE_CLIENT_AUTH_METHOD=
|
||||
# AUTH_GOOGLE_CHECKS=
|
||||
# AUTH_GOOGLE_ID_TOKEN_SIGNED_RESPONSE_ALG=
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
# AUTH_GITHUB_ALLOW_ACCOUNT_LINKING=false
|
||||
@@ -86,6 +87,7 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# AUTH_GITLAB_ISSUER=
|
||||
# AUTH_GITLAB_CLIENT_AUTH_METHOD=
|
||||
# AUTH_GITLAB_CHECKS=
|
||||
# AUTH_GITLAB_ID_TOKEN_SIGNED_RESPONSE_ALG=
|
||||
# AUTH_GITLAB_URL=
|
||||
# AUTH_AZURE_AD_CLIENT_ID=
|
||||
# AUTH_AZURE_AD_CLIENT_SECRET=
|
||||
@@ -93,30 +95,35 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# AUTH_AZURE_AD_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_AZURE_AD_CLIENT_AUTH_METHOD=
|
||||
# AUTH_AZURE_AD_CHECKS=
|
||||
# AUTH_AZURE_AD_ID_TOKEN_SIGNED_RESPONSE_ALG=
|
||||
# AUTH_OKTA_CLIENT_ID=
|
||||
# AUTH_OKTA_CLIENT_SECRET=
|
||||
# AUTH_OKTA_ISSUER=
|
||||
# AUTH_OKTA_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_OKTA_CLIENT_AUTH_METHOD=
|
||||
# AUTH_OKTA_CHECKS=
|
||||
# AUTH_OKTA_ID_TOKEN_SIGNED_RESPONSE_ALG=
|
||||
# AUTH_AUTH0_CLIENT_ID=
|
||||
# AUTH_AUTH0_CLIENT_SECRET=
|
||||
# AUTH_AUTH0_ISSUER=
|
||||
# AUTH_AUTH0_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_AUTH0_CLIENT_AUTH_METHOD=
|
||||
# AUTH_AUTH0_CHECKS=
|
||||
# AUTH_AUTH0_ID_TOKEN_SIGNED_RESPONSE_ALG=
|
||||
# AUTH_COGNITO_CLIENT_ID=
|
||||
# AUTH_COGNITO_CLIENT_SECRET=
|
||||
# AUTH_COGNITO_ISSUER=
|
||||
# AUTH_COGNITO_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_COGNITO_CLIENT_AUTH_METHOD=
|
||||
# AUTH_COGNITO_CHECKS=
|
||||
# AUTH_COGNITO_ID_TOKEN_SIGNED_RESPONSE_ALG=
|
||||
# AUTH_KEYCLOAK_CLIENT_ID=
|
||||
# AUTH_KEYCLOAK_CLIENT_SECRET=
|
||||
# AUTH_KEYCLOAK_ISSUER=
|
||||
# AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING=false
|
||||
# AUTH_KEYCLOAK_CLIENT_AUTH_METHOD=
|
||||
# AUTH_KEYCLOAK_CHECKS=
|
||||
# AUTH_KEYCLOAK_ID_TOKEN_SIGNED_RESPONSE_ALG=
|
||||
# AUTH_KEYCLOAK_NAME=
|
||||
# AUTH_WORKOS_CLIENT_ID=
|
||||
# AUTH_WORKOS_CLIENT_SECRET=
|
||||
@@ -133,12 +140,14 @@ OTEL_SERVICE_NAME="langfuse"
|
||||
# AUTH_CUSTOM_ID_TOKEN=false # optional, default is true
|
||||
# AUTH_CUSTOM_CLIENT_AUTH_METHOD=
|
||||
# AUTH_CUSTOM_CHECKS=
|
||||
# AUTH_CUSTOM_ID_TOKEN_SIGNED_RESPONSE_ALG=
|
||||
# AUTH_JUMPCLOUD_CLIENT_ID=
|
||||
# AUTH_JUMPCLOUD_CLIENT_SECRET=
|
||||
# AUTH_JUMPCLOUD_ISSUER=
|
||||
# AUTH_JUMPCLOUD_ALLOW_ACCOUNT_LINKING=
|
||||
# AUTH_JUMPCLOUD_CLIENT_AUTH_METHOD=
|
||||
# AUTH_JUMPCLOUD_CHECKS=
|
||||
# AUTH_JUMPCLOUD_ID_TOKEN_SIGNED_RESPONSE_ALG=
|
||||
# AUTH_JUMPCLOUD_SCOPE=
|
||||
|
||||
# Transactional email, optional
|
||||
|
||||
@@ -15,10 +15,10 @@ Fixes # (issue)
|
||||
<!-- Please delete bullets that are not relevant. -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] Chore (refactoring code, technical debt, workflow improvements)
|
||||
- [ ] Chore (tooling, dependencies, CI, workflows, repo upkeep, or other maintenance work)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Refactor (does not change functionality, e.g. code style improvements, linting)
|
||||
- [ ] Refactor (restructures existing code without changing behavior, e.g. simplify logic, split modules, reduce duplication)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## Mandatory Tasks
|
||||
|
||||
@@ -12,7 +12,7 @@ updates:
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
versioning-strategy: "increase"
|
||||
commit-message:
|
||||
prefix: chore
|
||||
@@ -54,6 +54,8 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 8
|
||||
commit-message:
|
||||
prefix: ci
|
||||
include: scope
|
||||
|
||||
@@ -10,10 +10,21 @@ on:
|
||||
type: string
|
||||
description: Name of the service to be deployed, e.g. web-ingestion, web, or worker.
|
||||
required: true
|
||||
# Environment secrets don't auto-resolve in reusable workflows.
|
||||
# See: https://github.com/actions/runner/issues/3206
|
||||
secrets:
|
||||
AWS_ACCESS_KEY_ID:
|
||||
required: true
|
||||
AWS_SECRET_ACCESS_KEY:
|
||||
required: true
|
||||
SENTRY_AUTH_TOKEN:
|
||||
required: false
|
||||
jobs:
|
||||
ecs-deploy:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
environment: ${{ inputs.environment }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Get app name
|
||||
uses: winterjung/split@a211a1c46e35fcdc4097d59dd6282d4a9859651b # v2
|
||||
@@ -22,52 +33,68 @@ jobs:
|
||||
msg: ${{ inputs.service }}
|
||||
separator: "-"
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Authenticate with AWS
|
||||
# GitHub/AWS recommend to use OIDC here: https://github.com/aws-actions/configure-aws-credentials?tab=readme-ov-file#oidc
|
||||
# Probably more painful to configure, but would remove all long-lived credentials.
|
||||
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4
|
||||
uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Login to AWS ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@183a1442edf41672e66566b7fc560e297a290896 # v2
|
||||
uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2.1.2
|
||||
- name: Build, tag, and push Docker image
|
||||
env:
|
||||
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||
REPOSITORY: ${{ steps.split.outputs._0 }}
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
STEPS_SPLIT_OUTPUTS__0: ${{ steps.split.outputs._0 }}
|
||||
VARS_NEXT_PUBLIC_LANGFUSE_CLOUD_REGION: ${{ vars.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION }}
|
||||
VARS_NEXT_LANGFUSE_TRACING_SAMPLE_RATE: ${{ vars.NEXT_LANGFUSE_TRACING_SAMPLE_RATE }}
|
||||
VARS_NEXT_PUBLIC_SENTRY_ENVIRONMENT: ${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }}
|
||||
VARS_NEXT_PUBLIC_DEMO_ORG_ID: ${{ vars.NEXT_PUBLIC_DEMO_ORG_ID }}
|
||||
VARS_NEXT_PUBLIC_DEMO_PROJECT_ID: ${{ vars.NEXT_PUBLIC_DEMO_PROJECT_ID }}
|
||||
VARS_NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
|
||||
VARS_NEXT_PUBLIC_POSTHOG_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_KEY }}
|
||||
VARS_NEXT_PUBLIC_POSTHOG_HOST: ${{ vars.NEXT_PUBLIC_POSTHOG_HOST }}
|
||||
VARS_NEXT_PUBLIC_PLAIN_APP_ID: ${{ vars.NEXT_PUBLIC_PLAIN_APP_ID }}
|
||||
VARS_SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
VARS_SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
|
||||
VARS_NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE: ${{ vars.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE }}
|
||||
SECRETS_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: |
|
||||
docker build \
|
||||
-t $REGISTRY/$REPOSITORY:$IMAGE_TAG \
|
||||
-f ./${{ steps.split.outputs._0 }}/Dockerfile \
|
||||
--build-arg NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=${{ vars.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION }} \
|
||||
--build-arg NEXT_LANGFUSE_TRACING_SAMPLE_RATE=${{ vars.NEXT_LANGFUSE_TRACING_SAMPLE_RATE }} \
|
||||
--build-arg NEXT_PUBLIC_SENTRY_ENVIRONMENT=${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} \
|
||||
--build-arg NEXT_PUBLIC_DEMO_ORG_ID=${{ vars.NEXT_PUBLIC_DEMO_ORG_ID }} \
|
||||
--build-arg NEXT_PUBLIC_DEMO_PROJECT_ID=${{ vars.NEXT_PUBLIC_DEMO_PROJECT_ID }} \
|
||||
--build-arg NEXT_PUBLIC_SENTRY_DSN=${{ vars.NEXT_PUBLIC_SENTRY_DSN }} \
|
||||
--build-arg NEXT_PUBLIC_BUILD_ID=${{ github.sha }} \
|
||||
--build-arg NEXT_PUBLIC_POSTHOG_KEY=${{ vars.NEXT_PUBLIC_POSTHOG_KEY }} \
|
||||
--build-arg NEXT_PUBLIC_POSTHOG_HOST=${{ vars.NEXT_PUBLIC_POSTHOG_HOST }} \
|
||||
--build-arg NEXT_PUBLIC_PLAIN_APP_ID=${{ vars.NEXT_PUBLIC_PLAIN_APP_ID }} \
|
||||
--build-arg SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \
|
||||
--build-arg SENTRY_ORG=${{ vars.SENTRY_ORG }} \
|
||||
--build-arg SENTRY_PROJECT=${{ vars.SENTRY_PROJECT }} \
|
||||
--build-arg NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE=${{ vars.NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE }} \
|
||||
-f ./${STEPS_SPLIT_OUTPUTS__0}/Dockerfile \
|
||||
--build-arg NEXT_PUBLIC_LANGFUSE_CLOUD_REGION=${VARS_NEXT_PUBLIC_LANGFUSE_CLOUD_REGION} \
|
||||
--build-arg NEXT_LANGFUSE_TRACING_SAMPLE_RATE=${VARS_NEXT_LANGFUSE_TRACING_SAMPLE_RATE} \
|
||||
--build-arg NEXT_PUBLIC_SENTRY_ENVIRONMENT=${VARS_NEXT_PUBLIC_SENTRY_ENVIRONMENT} \
|
||||
--build-arg NEXT_PUBLIC_DEMO_ORG_ID=${VARS_NEXT_PUBLIC_DEMO_ORG_ID} \
|
||||
--build-arg NEXT_PUBLIC_DEMO_PROJECT_ID=${VARS_NEXT_PUBLIC_DEMO_PROJECT_ID} \
|
||||
--build-arg NEXT_PUBLIC_SENTRY_DSN=${VARS_NEXT_PUBLIC_SENTRY_DSN} \
|
||||
--build-arg NEXT_PUBLIC_BUILD_ID=${IMAGE_TAG} \
|
||||
--build-arg NEXT_PUBLIC_POSTHOG_KEY=${VARS_NEXT_PUBLIC_POSTHOG_KEY} \
|
||||
--build-arg NEXT_PUBLIC_POSTHOG_HOST=${VARS_NEXT_PUBLIC_POSTHOG_HOST} \
|
||||
--build-arg NEXT_PUBLIC_PLAIN_APP_ID=${VARS_NEXT_PUBLIC_PLAIN_APP_ID} \
|
||||
--build-arg SENTRY_AUTH_TOKEN=${SECRETS_SENTRY_AUTH_TOKEN} \
|
||||
--build-arg SENTRY_ORG=${VARS_SENTRY_ORG} \
|
||||
--build-arg SENTRY_PROJECT=${VARS_SENTRY_PROJECT} \
|
||||
--build-arg NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE=${VARS_NEXT_PUBLIC_LANGFUSE_TRACING_SAMPLE_RATE} \
|
||||
.
|
||||
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
|
||||
- name: Render AWS ECS Task Definition
|
||||
id: render-task-definition
|
||||
uses: aws-actions/amazon-ecs-render-task-definition@77954e213ba1f9f9cb016b86a1d4f6fcdea0d57e # v1
|
||||
uses: aws-actions/amazon-ecs-render-task-definition@77954e213ba1f9f9cb016b86a1d4f6fcdea0d57e # v1.8.4
|
||||
with:
|
||||
container-name: ${{ inputs.service }}
|
||||
image: ${{ steps.login-ecr.outputs.registry }}/${{ steps.split.outputs._0 }}:${{ github.sha }}
|
||||
task-definition-family: ${{ inputs.environment }}-${{ inputs.service }}
|
||||
- name: Update AWS ECS Service
|
||||
uses: aws-actions/amazon-ecs-deploy-task-definition@fc8fc60f3a60ffd500fcb13b209c59d221ac8c8c # v2
|
||||
uses: aws-actions/amazon-ecs-deploy-task-definition@fc8fc60f3a60ffd500fcb13b209c59d221ac8c8c # v2.6.1
|
||||
with:
|
||||
task-definition: ${{ steps.render-task-definition.outputs.task-definition }}
|
||||
service: ${{ inputs.environment }}-${{ inputs.service }}
|
||||
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
retrigger_cla:
|
||||
# Only run on PR comments (not issue comments) with the /check-cla command
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
name: Claude Review on Maintainer PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- ready_for_review
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.pull_request.draft == false
|
||||
# Only run on PRs that are not drafts and are from the same repository (i.e., not from forks)
|
||||
if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -16,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check author permission and existing review request
|
||||
id: check
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
@@ -57,7 +58,7 @@ jobs:
|
||||
|
||||
- name: Add Claude review comment
|
||||
if: steps.check.outputs.should_comment == 'true'
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
@@ -55,11 +55,13 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 # v3
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -87,6 +89,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 # v3
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -22,6 +22,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Codespell
|
||||
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2
|
||||
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
rebase-dependabot:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -27,6 +27,8 @@ on:
|
||||
- prod-jp
|
||||
required: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
# Support concurrent `push` and `workflow_dispatch`` actions
|
||||
group: deploy-${{ github.event_name }}-${{ github.ref }}
|
||||
@@ -41,7 +43,7 @@ jobs:
|
||||
steps:
|
||||
- name: Get affected services
|
||||
id: affected-services
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
@@ -56,7 +58,7 @@ jobs:
|
||||
return "[]"
|
||||
result-encoding: string
|
||||
- name: Print services to build
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
services: ${{ steps.affected-services.outputs.result }}
|
||||
with:
|
||||
@@ -71,7 +73,7 @@ jobs:
|
||||
steps:
|
||||
- name: Get affected environments
|
||||
id: affected-environments
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
@@ -88,7 +90,7 @@ jobs:
|
||||
return "[]"
|
||||
result-encoding: string
|
||||
- name: Print environments to build
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
environments: ${{ steps.affected-environments.outputs.result }}
|
||||
with:
|
||||
@@ -99,7 +101,14 @@ jobs:
|
||||
ecs-deploy:
|
||||
uses: ./.github/workflows/_deploy_ecs_service.yml
|
||||
needs: [affected-services, affected-environments]
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
# Environment secrets must be passed explicitly to reusable workflows.
|
||||
# See: https://github.com/actions/runner/issues/3206
|
||||
secrets:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
strategy:
|
||||
matrix:
|
||||
service: ${{ fromJson(needs.affected-services.outputs.services) }}
|
||||
|
||||
@@ -9,15 +9,21 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write # Needed to create a check run for the license compliance check results
|
||||
|
||||
jobs:
|
||||
license_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
@@ -32,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Check license-checker CSV file without headers
|
||||
id: license_check_report
|
||||
uses: pilosus/action-pip-license-checker@cc7a461bfa27b44ad187b8578c881ef5138c13fd # v2
|
||||
uses: pilosus/action-pip-license-checker@e909b0226ff49d3235c99c4585bc617f49fff16a # v3.1.0
|
||||
with:
|
||||
external: "npm-license-checker.csv"
|
||||
external-format: "csv"
|
||||
@@ -44,6 +50,8 @@ jobs:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Echo error
|
||||
if: failure()
|
||||
run: echo "::error::${{ steps.license_check_report.outputs.report }}"
|
||||
run: echo "::error::${STEPS_LICENSE_CHECK_REPORT_OUTPUTS_REPORT}"
|
||||
env:
|
||||
STEPS_LICENSE_CHECK_REPORT_OUTPUTS_REPORT: ${{ steps.license_check_report.outputs.report }}
|
||||
- name: Delete license-checker CSV file
|
||||
run: rm npm-license-checker.csv
|
||||
|
||||
+402
-108
@@ -12,6 +12,9 @@ on:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
@@ -24,19 +27,39 @@ jobs:
|
||||
llm_connections_changed: ${{ steps.filter.outputs.llm_connections }}
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
# Replaces fkirc/skip-duplicate-actions — skips runs whose git tree
|
||||
# was already tested in a prior successful run of this workflow.
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5
|
||||
with:
|
||||
do_not_skip: '["workflow_dispatch"]'
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
echo "should_skip=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CURRENT_TREE=$(gh api "repos/${{ github.repository }}/git/commits/${{ github.sha }}" --jq '.tree.sha')
|
||||
|
||||
MATCH=$(gh api "repos/${{ github.repository }}/actions/workflows/pipeline.yml/runs?status=success&per_page=20" \
|
||||
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }} and .head_commit.tree_id == \"$CURRENT_TREE\")] | first | .head_sha // empty")
|
||||
|
||||
if [[ -n "$MATCH" ]]; then
|
||||
echo "::notice::Tree $CURRENT_TREE already tested in a prior successful run (commit $MATCH) — skipping"
|
||||
echo "should_skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "should_skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Recommended for paths-filter action
|
||||
# may save additional git fetch roundtrip if
|
||||
# merge-base is found within latest N commits
|
||||
fetch-depth: 20
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -50,17 +73,19 @@ jobs:
|
||||
- pre-job
|
||||
if: needs.pre-job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.33.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 # zizmor: ignore[cache-poisoning] lint job dependency cache only; no released artifacts are built or published from this cached state
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: Setup Turbo cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 # zizmor: ignore[cache-poisoning] lint cache only; publish jobs rebuild artifacts and do not restore this cache
|
||||
with:
|
||||
path: .turbo
|
||||
key: ${{ runner.os }}-turbo-lint-${{ github.sha }}
|
||||
@@ -82,13 +107,14 @@ jobs:
|
||||
- pre-job
|
||||
if: needs.pre-job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.33.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 # zizmor: ignore[cache-poisoning] prettier check dependency cache only; no released artifacts are built or published from this cached state
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
@@ -129,10 +155,12 @@ jobs:
|
||||
DOCKER_COMPOSE_FILE: docker-compose.build.yml
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Login to Docker Hub
|
||||
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
@@ -178,7 +206,7 @@ jobs:
|
||||
done
|
||||
- name: Upload docker diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-docker-build-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: /tmp/docker-diagnostics
|
||||
@@ -197,23 +225,25 @@ jobs:
|
||||
deploy-mode: ["", "-azure", "-redis-cluster"]
|
||||
shard: [1, 2, 3]
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install golang-migrate for Clickhouse migrations
|
||||
run: |
|
||||
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz
|
||||
sudo mv migrate /usr/bin/migrate
|
||||
which migrate
|
||||
- uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.33.0
|
||||
- name: Login to Docker Hub
|
||||
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 # zizmor: ignore[cache-poisoning] test job dependency cache only; no released artifacts are built or published from this cached state
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
@@ -231,15 +261,16 @@ jobs:
|
||||
echo "LANGFUSE_TRACE_DELETE_CONCURRENCY=100" >> .env
|
||||
echo "ADMIN_API_KEY=admin-api-key" >> .env
|
||||
echo "LANGFUSE_EE_LICENSE_KEY=langfuse_ee_test" >> .env
|
||||
echo "LANGFUSE_SKIP_EVALUATOR_MODEL_CALL_VALIDATION=true" >> .env
|
||||
- name: Setup Turbo cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 # zizmor: ignore[cache-poisoning] test job cache only; publish jobs rebuild artifacts and do not restore this cache
|
||||
with:
|
||||
path: .turbo
|
||||
key: ${{ runner.os }}-turbo-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-turbo-
|
||||
- name: Cache Next.js builds
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 # zizmor: ignore[cache-poisoning] test-only Next.js cache; not consumed by artifact publishing or release jobs
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
@@ -291,11 +322,11 @@ jobs:
|
||||
LANGFUSE_INIT_USER_PASSWORD: "password"
|
||||
- name: run tests
|
||||
working-directory: web
|
||||
run: npx dotenv -e ../.env.test -e ../.env -- npx jest --verbose --runInBand --detectOpenHandles --selectProjects server --shard=${{ matrix.shard }}/3
|
||||
run: npx dotenv -e ../.env.test -e ../.env -- vitest run --project server --shard=${{ matrix.shard }}/3
|
||||
- name: run test-client
|
||||
if: matrix.deploy-mode == '' && matrix.shard == 1
|
||||
working-directory: web
|
||||
run: npx dotenv -e ../.env.test -e ../.env -- npx jest --verbose --runInBand --detectOpenHandles --selectProjects client
|
||||
run: npx dotenv -e ../.env.test -e ../.env -- vitest run --project client
|
||||
|
||||
tests-worker:
|
||||
timeout-minutes: 20
|
||||
@@ -310,18 +341,20 @@ jobs:
|
||||
postgres-version: [12, 15]
|
||||
deploy-mode: ["", "-azure", "-redis-cluster"]
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.33.0
|
||||
- name: Login to Docker Hub
|
||||
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 # zizmor: ignore[cache-poisoning] worker test dependency cache only; no release artifacts are produced from this cache
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
@@ -381,18 +414,20 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/') || (needs.pre-job.outputs.should_skip != 'true' && (needs.pre-job.outputs.llm_connections_changed == 'true' || github.event_name == 'workflow_dispatch'))
|
||||
name: test-worker-llm-connections (node24, pg15)
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.33.0
|
||||
- name: Login to Docker Hub
|
||||
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
- name: Use Node.js 24
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 # zizmor: ignore[cache-poisoning] llm-connection test dependency cache only; privileged secrets are used here, but this cache is not consumed by artifact publishing
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
@@ -442,6 +477,7 @@ jobs:
|
||||
LANGFUSE_LLM_CONNECTION_BEDROCK_ACCESS_KEY_ID: ${{ secrets.LANGFUSE_LLM_CONNECTION_BEDROCK_ACCESS_KEY_ID }}
|
||||
LANGFUSE_LLM_CONNECTION_BEDROCK_SECRET_ACCESS_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_BEDROCK_SECRET_ACCESS_KEY }}
|
||||
LANGFUSE_LLM_CONNECTION_BEDROCK_REGION: ${{ secrets.LANGFUSE_LLM_CONNECTION_BEDROCK_REGION }}
|
||||
LANGFUSE_LLM_CONNECTION_BEDROCK_API_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_BEDROCK_API_KEY }}
|
||||
LANGFUSE_LLM_CONNECTION_VERTEXAI_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_VERTEXAI_KEY }}
|
||||
LANGFUSE_LLM_CONNECTION_GOOGLEAISTUDIO_KEY: ${{ secrets.LANGFUSE_LLM_CONNECTION_GOOGLEAISTUDIO_KEY }}
|
||||
|
||||
@@ -451,23 +487,25 @@ jobs:
|
||||
- pre-job
|
||||
if: needs.pre-job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.33.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 # zizmor: ignore[cache-poisoning] e2e dependency cache only; release images are rebuilt later without restoring this cache
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: Login to Docker Hub
|
||||
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
- name: Setup Turbo cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 # zizmor: ignore[cache-poisoning] e2e cache only; no published artifact path restores this cache
|
||||
with:
|
||||
path: .turbo
|
||||
key: ${{ runner.os }}-turbo-e2e-${{ github.sha }}
|
||||
@@ -475,7 +513,7 @@ jobs:
|
||||
${{ runner.os }}-turbo-e2e-
|
||||
${{ runner.os }}-turbo-
|
||||
- name: Cache Next.js builds
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 # zizmor: ignore[cache-poisoning] e2e-only Next.js cache; not part of any artifact build or publishing flow
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
@@ -528,17 +566,19 @@ jobs:
|
||||
- pre-job
|
||||
if: needs.pre-job.outputs.should_skip != 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Login to Docker Hub
|
||||
if: github.repository == 'langfuse/langfuse' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME_READ }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN_READ }}
|
||||
- uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3
|
||||
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.33.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 # zizmor: ignore[cache-poisoning] server e2e dependency cache only; release/publish steps rebuild separately
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
@@ -589,7 +629,8 @@ jobs:
|
||||
all-ci-passed:
|
||||
# This allows us to have a branch protection rule for tests and deploys with matrix
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
needs: [
|
||||
needs:
|
||||
[
|
||||
lint,
|
||||
prettier-check,
|
||||
tests-web,
|
||||
@@ -620,62 +661,222 @@ jobs:
|
||||
run: exit 1
|
||||
working-directory: .
|
||||
- name: Notify Slack
|
||||
uses: ravsamhq/notify-slack-action@be814b201e233b2dc673608aa46e5447c8ab13f2 # v2
|
||||
if: always() && github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||
if: failure() && github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||
uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1
|
||||
with:
|
||||
status: ${{ job.status }}
|
||||
notify_when: "failure"
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"text": "❌ CI failed on ${{ github.ref_name }}",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "❌ CI Failed",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Branch/Tag:*\n`${{ github.ref_name }}`"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Triggered by:*\n${{ github.actor }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View Workflow Logs",
|
||||
"emoji": true
|
||||
},
|
||||
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
|
||||
"style": "danger"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
- name: Output job results
|
||||
run: |
|
||||
echo "Job results: ${{ steps.set-success-output.outputs.success }}"
|
||||
echo "Job results: ${STEPS_SET_SUCCESS_OUTPUT_OUTPUTS_SUCCESS}"
|
||||
env:
|
||||
STEPS_SET_SUCCESS_OUTPUT_OUTPUTS_SUCCESS: ${{ steps.set-success-output.outputs.success }}
|
||||
|
||||
push-docker-image:
|
||||
build-docker-image-release:
|
||||
needs: all-ci-passed
|
||||
# if something inside all-ci-passed was skipped, but everything that ran passed, we still want to deploy
|
||||
if: always() && needs.all-ci-passed.outputs.success == 'true' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
environment: "protected branches"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- component: web
|
||||
image_name: langfuse
|
||||
dockerfile: ./web/Dockerfile
|
||||
platform: linux/amd64
|
||||
platform_tag: amd64
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- component: web
|
||||
image_name: langfuse
|
||||
dockerfile: ./web/Dockerfile
|
||||
platform: linux/arm64
|
||||
platform_tag: arm64
|
||||
runner: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- component: worker
|
||||
image_name: langfuse-worker
|
||||
dockerfile: ./worker/Dockerfile
|
||||
platform: linux/amd64
|
||||
platform_tag: amd64
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- component: worker
|
||||
image_name: langfuse-worker
|
||||
dockerfile: ./worker/Dockerfile
|
||||
platform: linux/arm64
|
||||
platform_tag: arm64
|
||||
runner: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@5241b2e9423e8b1fa37ed6050ecb62d0fb9a4e38 # v1.6.0
|
||||
- name: Extract metadata (labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/langfuse/${{ matrix.image_name }}
|
||||
langfuse/${{ matrix.image_name }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=semver,pattern={{major}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') && !contains(github.ref, '-rc') }}
|
||||
- name: Build and push image by digest to GitHub Container Registry (${{ matrix.component }}, ${{ matrix.platform_tag }})
|
||||
id: build-ghcr
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
outputs: type=image,name=ghcr.io/langfuse/${{ matrix.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
- name: Build and push image by digest to Docker Hub (${{ matrix.component }}, ${{ matrix.platform_tag }})
|
||||
id: build-dockerhub
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
outputs: type=image,name=langfuse/${{ matrix.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
- name: Record pushed digests
|
||||
env:
|
||||
DIGEST_GHCR: ${{ steps.build-ghcr.outputs.digest }}
|
||||
DIGEST_DOCKERHUB: ${{ steps.build-dockerhub.outputs.digest }}
|
||||
PLATFORM_TAG: ${{ matrix.platform_tag }}
|
||||
run: |
|
||||
if [ -z "$DIGEST_GHCR" ] || [ -z "$DIGEST_DOCKERHUB" ]; then
|
||||
echo "Missing registry digest output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$RUNNER_TEMP/digests/ghcr" "$RUNNER_TEMP/digests/dockerhub"
|
||||
printf '%s\n' "$DIGEST_GHCR" > "$RUNNER_TEMP/digests/ghcr/${PLATFORM_TAG}.txt"
|
||||
printf '%s\n' "$DIGEST_DOCKERHUB" > "$RUNNER_TEMP/digests/dockerhub/${PLATFORM_TAG}.txt"
|
||||
- name: Upload release digests
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-digests-${{ matrix.component }}-${{ matrix.platform_tag }}
|
||||
path: |
|
||||
${{ runner.temp }}/digests/ghcr/${{ matrix.platform_tag }}.txt
|
||||
${{ runner.temp }}/digests/dockerhub/${{ matrix.platform_tag }}.txt
|
||||
if-no-files-found: error
|
||||
|
||||
publish-docker-image-release:
|
||||
needs:
|
||||
- build-docker-image-release
|
||||
if: always() && needs.build-docker-image-release.result == 'success' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
environment: "protected branches"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- component: web
|
||||
image_name: langfuse
|
||||
- component: worker
|
||||
image_name: langfuse-worker
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3
|
||||
with:
|
||||
version: 10.33.0
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Set NEXT_PUBLIC_BUILD_ID
|
||||
run: echo "NEXT_PUBLIC_BUILD_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@5241b2e9423e8b1fa37ed6050ecb62d0fb9a4e38 # v1
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175 # v4
|
||||
uses: useblacksmith/setup-docker-builder@5241b2e9423e8b1fa37ed6050ecb62d0fb9a4e38 # v1.6.0
|
||||
- name: Download release digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/langfuse/langfuse # GitHub
|
||||
langfuse/langfuse # Docker Hub
|
||||
pattern: release-digests-${{ matrix.component }}-*
|
||||
merge-multiple: true
|
||||
path: ${{ runner.temp }}/digests
|
||||
- name: Extract metadata (tags) for GitHub Container Registry
|
||||
id: meta-ghcr
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/langfuse/${{ matrix.image_name }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
@@ -686,24 +887,11 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=semver,pattern={{major}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') && !contains(github.ref, '-rc') }}
|
||||
- name: Build and push Docker image (web)
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
- name: Extract metadata (tags) for Docker Hub
|
||||
id: meta-dockerhub
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./web/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
${{ startsWith(github.ref, 'refs/tags/') && 'linux/arm64' || '' }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta-worker
|
||||
uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175 # v4
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/langfuse/langfuse-worker # GitHub
|
||||
langfuse/langfuse-worker # Docker Hub
|
||||
images: langfuse/${{ matrix.image_name }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
@@ -714,22 +902,128 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=semver,pattern={{major}},enable=${{ !contains(github.ref, '-rc') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v3') && !contains(github.ref, '-rc') }}
|
||||
- name: Build and push Docker image (worker)
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
with:
|
||||
context: .
|
||||
file: ./worker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta-worker.outputs.tags }}
|
||||
labels: ${{ steps.meta-worker.outputs.labels }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
${{ startsWith(github.ref, 'refs/tags/') && 'linux/arm64' || '' }}
|
||||
- name: Notify Slack
|
||||
uses: ravsamhq/notify-slack-action@be814b201e233b2dc673608aa46e5447c8ab13f2 # v2
|
||||
if: always()
|
||||
with:
|
||||
status: ${{ job.status }}
|
||||
notify_when: "failure"
|
||||
- name: Publish multi-platform manifest to GitHub Container Registry
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
STEPS_META_GHCR_OUTPUTS_TAGS: ${{ steps.meta-ghcr.outputs.tags }}
|
||||
IMAGE_NAME: ${{ matrix.image_name }}
|
||||
COMPONENT: ${{ matrix.component }}
|
||||
run: |
|
||||
ghcr_tags=()
|
||||
ghcr_sources=()
|
||||
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
ghcr_tags+=("-t" "$tag")
|
||||
done <<EOF
|
||||
${STEPS_META_GHCR_OUTPUTS_TAGS}
|
||||
EOF
|
||||
|
||||
shopt -s nullglob
|
||||
for digest_file in "$RUNNER_TEMP"/digests/ghcr/*.txt; do
|
||||
digest="$(cat "$digest_file")"
|
||||
ghcr_sources+=("ghcr.io/langfuse/${IMAGE_NAME}@$digest")
|
||||
done
|
||||
|
||||
if [ "${#ghcr_sources[@]}" -lt 2 ]; then
|
||||
echo "Expected amd64 and arm64 GHCR digests for $COMPONENT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker buildx imagetools create "${ghcr_tags[@]}" "${ghcr_sources[@]}"
|
||||
- name: Publish multi-platform manifest to Docker Hub
|
||||
env:
|
||||
STEPS_META_DOCKERHUB_OUTPUTS_TAGS: ${{ steps.meta-dockerhub.outputs.tags }}
|
||||
IMAGE_NAME: ${{ matrix.image_name }}
|
||||
COMPONENT: ${{ matrix.component }}
|
||||
run: |
|
||||
dockerhub_tags=()
|
||||
dockerhub_sources=()
|
||||
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
dockerhub_tags+=("-t" "$tag")
|
||||
done <<EOF
|
||||
${STEPS_META_DOCKERHUB_OUTPUTS_TAGS}
|
||||
EOF
|
||||
|
||||
shopt -s nullglob
|
||||
for digest_file in "$RUNNER_TEMP"/digests/dockerhub/*.txt; do
|
||||
digest="$(cat "$digest_file")"
|
||||
dockerhub_sources+=("langfuse/${IMAGE_NAME}@$digest")
|
||||
done
|
||||
|
||||
if [ "${#dockerhub_sources[@]}" -lt 2 ]; then
|
||||
echo "Expected amd64 and arm64 Docker Hub digests for $COMPONENT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker buildx imagetools create "${dockerhub_tags[@]}" "${dockerhub_sources[@]}"
|
||||
- name: Inspect published manifests
|
||||
run: |
|
||||
ghcr_first_tag="$(printf '%s\n' "${STEPS_META_GHCR_OUTPUTS_TAGS}" | sed -n '1p')"
|
||||
dockerhub_first_tag="$(printf '%s\n' "${STEPS_META_DOCKERHUB_OUTPUTS_TAGS}" | sed -n '1p')"
|
||||
|
||||
docker buildx imagetools inspect "$ghcr_first_tag"
|
||||
docker buildx imagetools inspect "$dockerhub_first_tag"
|
||||
env:
|
||||
STEPS_META_GHCR_OUTPUTS_TAGS: ${{ steps.meta-ghcr.outputs.tags }}
|
||||
STEPS_META_DOCKERHUB_OUTPUTS_TAGS: ${{ steps.meta-dockerhub.outputs.tags }}
|
||||
|
||||
notify-docker-image-release:
|
||||
needs:
|
||||
- build-docker-image-release
|
||||
- publish-docker-image-release
|
||||
if: always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Fail when a release image job failed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
- name: Notify Slack
|
||||
if: failure()
|
||||
uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"text": "❌ Docker release failed on ${{ github.ref_name }}",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "❌ Docker Release Failed",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Tag:*\n`${{ github.ref_name }}`"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Triggered by:*\n${{ github.actor }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View Workflow Logs",
|
||||
"emoji": true
|
||||
},
|
||||
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
|
||||
"style": "danger"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ concurrency:
|
||||
group: promote-main-to-production
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,16 +21,19 @@ jobs:
|
||||
steps:
|
||||
- name: Validate confirmation
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.confirm }}" != "promote" ]; then
|
||||
if [ "${INPUT_CONFIRM}" != "promote" ]; then
|
||||
echo "Input 'confirm' must be 'promote'."
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
INPUT_CONFIRM: ${{ github.event.inputs.confirm }}
|
||||
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Print commit refs
|
||||
run: |
|
||||
@@ -41,4 +46,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Force push main to production
|
||||
run: git push origin +main:production
|
||||
run: git push "https://x-access-token:${GH_ACCESS_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" +main:production
|
||||
env:
|
||||
GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
|
||||
@@ -5,15 +5,20 @@ on:
|
||||
tags:
|
||||
- "v3.[0-9]+.[0-9]+" # Semantic version tags
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
environment: "protected branches"
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: main # Always checkout main even for tagged releases
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
persist-credentials: false
|
||||
- name: Push to production
|
||||
run: git push origin +main:production
|
||||
run: git push "https://x-access-token:${GH_ACCESS_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" +main:production
|
||||
env:
|
||||
GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
|
||||
@@ -12,20 +12,26 @@ concurrency:
|
||||
group: sdk-api-spec-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-sdk-api-specs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: "protected branches"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
@@ -39,7 +45,7 @@ jobs:
|
||||
run: npx fern-api generate --api server --force
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
with:
|
||||
version: "0.11.2"
|
||||
|
||||
|
||||
@@ -3,46 +3,55 @@ on:
|
||||
push:
|
||||
branches: ["production", "main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
snyk:
|
||||
runs-on: ubuntu-latest
|
||||
environment: snyk
|
||||
steps:
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Snyk CLI
|
||||
uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # master
|
||||
with:
|
||||
snyk-version: v1.1304.0
|
||||
- name: Run Snyk to check Docker image for vulnerabilities
|
||||
# Snyk can be used to break the build when it detects vulnerabilities.
|
||||
# In this case we want to upload the issues to GitHub Code Scanning
|
||||
continue-on-error: true
|
||||
uses: snyk/actions/docker@9adf32b1121593767fc3c057af55b55db032dc04 # master
|
||||
env:
|
||||
# In order to use the Snyk Action you will need to have a Snyk API token.
|
||||
# See https://docs.snyk.io/integrations/ci-cd-integrations/github-actions-integration#getting-your-snyk-token
|
||||
# or you can sign up for free at https://snyk.io/login
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
image: langfuse/langfuse
|
||||
args: --sarif-file-output=snyk.sarif
|
||||
- name: Fix SARIF file
|
||||
run: |
|
||||
snyk container test langfuse/langfuse \
|
||||
--file=web/Dockerfile \
|
||||
--sarif-file-output=snyk.sarif
|
||||
- name: Normalize SARIF file
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f snyk.sarif ]; then
|
||||
# Fix undefined security severity values in SARIF file
|
||||
sed -i 's/"security-severity": "undefined"/"security-severity": "0"/g' snyk.sarif
|
||||
# Snyk emits invalid security-severity values. upload-sarif requires a numeric string.
|
||||
sed -i \
|
||||
-e 's/"security-severity": "undefined"/"security-severity": "0"/g' \
|
||||
-e 's/"security-severity": "null"/"security-severity": "0"/g' \
|
||||
-e 's/"security-severity": null/"security-severity": "0"/g' \
|
||||
snyk.sarif
|
||||
echo "SARIF file fixed"
|
||||
else
|
||||
echo "No SARIF file found"
|
||||
fi
|
||||
- name: Echo SARIF file for debugging
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f snyk.sarif ]; then
|
||||
echo "=== SARIF File Contents ==="
|
||||
cat snyk.sarif
|
||||
echo "=== End of SARIF File ==="
|
||||
else
|
||||
echo "No SARIF file found to display"
|
||||
fi
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4
|
||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
category: snyk-container-web
|
||||
- name: Echo SARIF file for debugging
|
||||
if: failure() && hashFiles('snyk.sarif') != ''
|
||||
run: cat snyk.sarif
|
||||
|
||||
@@ -3,46 +3,55 @@ on:
|
||||
push:
|
||||
branches: ["production", "main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
snyk:
|
||||
runs-on: ubuntu-latest
|
||||
environment: snyk
|
||||
steps:
|
||||
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Snyk CLI
|
||||
uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # master
|
||||
with:
|
||||
snyk-version: v1.1304.0
|
||||
- name: Run Snyk to check Docker image for vulnerabilities
|
||||
# Snyk can be used to break the build when it detects vulnerabilities.
|
||||
# In this case we want to upload the issues to GitHub Code Scanning
|
||||
continue-on-error: true
|
||||
uses: snyk/actions/docker@9adf32b1121593767fc3c057af55b55db032dc04 # master
|
||||
env:
|
||||
# In order to use the Snyk Action you will need to have a Snyk API token.
|
||||
# See https://docs.snyk.io/integrations/ci-cd-integrations/github-actions-integration#getting-your-snyk-token
|
||||
# or you can sign up for free at https://snyk.io/login
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
image: langfuse/langfuse-worker
|
||||
args: --sarif-file-output=snyk.sarif
|
||||
- name: Fix SARIF file
|
||||
run: |
|
||||
snyk container test langfuse/langfuse-worker \
|
||||
--file=worker/Dockerfile \
|
||||
--sarif-file-output=snyk.sarif
|
||||
- name: Normalize SARIF file
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f snyk.sarif ]; then
|
||||
# Fix undefined security severity values in SARIF file
|
||||
sed -i 's/"security-severity": "undefined"/"security-severity": "0"/g' snyk.sarif
|
||||
# Snyk emits invalid security-severity values. upload-sarif requires a numeric string.
|
||||
sed -i \
|
||||
-e 's/"security-severity": "undefined"/"security-severity": "0"/g' \
|
||||
-e 's/"security-severity": "null"/"security-severity": "0"/g' \
|
||||
-e 's/"security-severity": null/"security-severity": "0"/g' \
|
||||
snyk.sarif
|
||||
echo "SARIF file fixed"
|
||||
else
|
||||
echo "No SARIF file found"
|
||||
fi
|
||||
- name: Echo SARIF file for debugging
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f snyk.sarif ]; then
|
||||
echo "=== SARIF File Contents ==="
|
||||
cat snyk.sarif
|
||||
echo "=== End of SARIF File ==="
|
||||
else
|
||||
echo "No SARIF file found to display"
|
||||
fi
|
||||
- name: Upload result to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4
|
||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: snyk.sarif
|
||||
category: snyk-container-worker
|
||||
- name: Echo SARIF file for debugging
|
||||
if: failure() && hashFiles('snyk.sarif') != ''
|
||||
run: cat snyk.sarif
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 14
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Validate PR title follows conventional commits
|
||||
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6
|
||||
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: Check GitHub Actions
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
merge_group:
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Check GitHub Actions security
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor (fork PR - fail on findings)
|
||||
# Fork PRs don't get security-events: write on GITHUB_TOKEN, so SARIF
|
||||
# upload isn't possible. Fail the job on findings instead so contributors
|
||||
# see the error directly in the PR check.
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
|
||||
- name: Run zizmor (trusted - upload SARIF)
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
with:
|
||||
# Means that the action will only report issues, but not fail the workflow.
|
||||
# Blocking merges are handled by rulesets:
|
||||
# https://docs.github.com/en/code-security/concepts/code-scanning/about-code-scanning-alerts#pull-request-check-failures-for-code-scanning-alerts
|
||||
advanced-security: true
|
||||
@@ -0,0 +1,21 @@
|
||||
rules:
|
||||
secrets-outside-env:
|
||||
config:
|
||||
allow:
|
||||
# Shared read-only CI credentials used to avoid Docker Hub rate limits in test jobs.
|
||||
- DOCKERHUB_USERNAME_READ
|
||||
- DOCKERHUB_TOKEN_READ
|
||||
# Shared integration-test credentials intentionally kept together for the multi-provider LLM connection test job.
|
||||
- LANGFUSE_LLM_CONNECTION_OPENAI_KEY
|
||||
- LANGFUSE_LLM_CONNECTION_ANTHROPIC_KEY
|
||||
- LANGFUSE_LLM_CONNECTION_AZURE_KEY
|
||||
- LANGFUSE_LLM_CONNECTION_AZURE_BASE_URL
|
||||
- LANGFUSE_LLM_CONNECTION_AZURE_MODEL
|
||||
- LANGFUSE_LLM_CONNECTION_BEDROCK_ACCESS_KEY_ID
|
||||
- LANGFUSE_LLM_CONNECTION_BEDROCK_SECRET_ACCESS_KEY
|
||||
- LANGFUSE_LLM_CONNECTION_BEDROCK_API_KEY
|
||||
- LANGFUSE_LLM_CONNECTION_BEDROCK_REGION
|
||||
- LANGFUSE_LLM_CONNECTION_VERTEXAI_KEY
|
||||
- LANGFUSE_LLM_CONNECTION_GOOGLEAISTUDIO_KEY
|
||||
# Fern token is generation-only; GitHub write actions are handled by GH_ACCESS_TOKEN in a protected environment.
|
||||
- FERN_TOKEN
|
||||
@@ -47,6 +47,7 @@ yarn-error.log*
|
||||
!.env.dev-redis-cluster.example
|
||||
!.env.prod.example
|
||||
!.env.test.example
|
||||
!.env.dev-oci.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
+5
-7
@@ -316,18 +316,16 @@ Tests automatically create the PostgreSQL test database if it doesn't exist and
|
||||
|
||||
### Tests in the `web` package (public API)
|
||||
|
||||
We're using Jest with in the `web` package. Therefore, if you want to provide an argument to the test runner, do it directly without an intermittent `--`.
|
||||
|
||||
There are two types of unit tests:
|
||||
We're using Vitest in the `web` package. There are two types of unit tests:
|
||||
|
||||
- `test` (server tests)
|
||||
- `test-client`
|
||||
|
||||
To run a specific test, for example the test: `"should handle special characters in prompt names"` in `prompts.v2.servertest.ts`, run:
|
||||
To run a specific test by name within a file, run:
|
||||
|
||||
```sh
|
||||
cd web # or with --filter=web
|
||||
pnpm test --testPathPatterns="prompts\.v2\.servertest" --testNamePattern="should handle special characters in prompt names"
|
||||
pnpm test -- prompts.v2.servertest -t "should handle special characters in prompt names"
|
||||
```
|
||||
|
||||
To run all tests:
|
||||
@@ -344,7 +342,7 @@ pnpm run test:watch
|
||||
|
||||
### Tests in the `worker` package
|
||||
|
||||
For the `worker` package, we're using `vitest` to run unit tests.
|
||||
For the `worker` package, we're also using Vitest to run unit tests.
|
||||
|
||||
```sh
|
||||
pnpm run test --filter=worker -- FILE_YOU_WANT_TO_TEST.ts -t "test name"
|
||||
@@ -357,7 +355,7 @@ We use GitHub Actions for CI/CD, the configuration is in [`.github/workflows/pip
|
||||
CI on `main` and `pull_request`
|
||||
|
||||
- Check Linting
|
||||
- E2E test of API using Jest
|
||||
- E2E test of API using Vitest
|
||||
- E2E tests of UI using Playwright
|
||||
|
||||
CD on `main`
|
||||
|
||||
@@ -31,6 +31,8 @@ services:
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-clickhouse} # CHANGEME
|
||||
CLICKHOUSE_CLUSTER_ENABLED: ${CLICKHOUSE_CLUSTER_ENABLED:-false}
|
||||
LANGFUSE_USE_AZURE_BLOB: ${LANGFUSE_USE_AZURE_BLOB:-false}
|
||||
LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE: ${LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE:-false}
|
||||
LANGFUSE_OCI_AUTH_TYPE: ${LANGFUSE_OCI_AUTH_TYPE:-workload_identity}
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: ${LANGFUSE_S3_EVENT_UPLOAD_BUCKET:-langfuse}
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION: ${LANGFUSE_S3_EVENT_UPLOAD_REGION:-auto}
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID:-minio}
|
||||
|
||||
@@ -56,6 +56,7 @@ types:
|
||||
|
||||
BlobStorageExportFrequency:
|
||||
enum:
|
||||
- every_20_minutes
|
||||
- hourly
|
||||
- daily
|
||||
- weekly
|
||||
@@ -138,8 +139,8 @@ types:
|
||||
- `up_to_date` — all available data has been exported; next export is scheduled for the future
|
||||
|
||||
**ETL usage**: poll this endpoint and check for `up_to_date` status. Compare `lastSyncAt` against your
|
||||
ETL bookmark to determine if new data is available. Note that exports run with a 30-minute lag buffer,
|
||||
so `lastSyncAt` will always be at least 30 minutes behind real-time.
|
||||
ETL bookmark to determine if new data is available. Note that exports run with a 20-minute lag buffer,
|
||||
so `lastSyncAt` will always be at least 20 minutes behind real-time.
|
||||
enum:
|
||||
- idle
|
||||
- queued
|
||||
|
||||
@@ -26,6 +26,13 @@ service:
|
||||
path: /llm-connections
|
||||
request: UpsertLlmConnectionRequest
|
||||
response: LlmConnection
|
||||
delete:
|
||||
method: DELETE
|
||||
docs: Delete an LLM connection by id. Evaluators that depend on the deleted connection are automatically paused.
|
||||
path: /llm-connections/{id}
|
||||
path-parameters:
|
||||
id: string
|
||||
response: DeleteLlmConnectionResponse
|
||||
|
||||
types:
|
||||
LlmConnection:
|
||||
@@ -96,6 +103,10 @@ types:
|
||||
- **VertexAI**: Optional. If provided, must be `{"location": "<gcp-location>"}` (e.g., `{"location":"us-central1"}`)
|
||||
- **Other adapters**: Not supported. Omit this field or set to null.
|
||||
|
||||
DeleteLlmConnectionResponse:
|
||||
properties:
|
||||
message: string
|
||||
|
||||
LlmAdapter:
|
||||
enum:
|
||||
- value: anthropic
|
||||
|
||||
@@ -54,7 +54,9 @@ types:
|
||||
meta: pagination.MetaResponse
|
||||
CreateScoreConfigRequest:
|
||||
properties:
|
||||
name: string
|
||||
name:
|
||||
type: string
|
||||
docs: "Name of the score config. Max 35 characters. Only letters, numbers, underscores, spaces, periods, parentheses, and hyphens are allowed."
|
||||
dataType: commons.ScoreConfigDataType
|
||||
categories:
|
||||
type: optional<list<commons.ConfigCategory>>
|
||||
@@ -75,7 +77,7 @@ types:
|
||||
docs: The status of the score config showing if it is archived or not
|
||||
name:
|
||||
type: optional<string>
|
||||
docs: The name of the score config
|
||||
docs: "Name of the score config. Max 35 characters. Only letters, numbers, underscores, spaces, periods, parentheses, and hyphens are allowed."
|
||||
categories:
|
||||
type: optional<list<commons.ConfigCategory>>
|
||||
docs: Configure custom categories for categorical scores. Pass a list of objects with `label` and `value` properties. Categories are autogenerated for boolean configs and cannot be passed
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
types:
|
||||
EvaluatorType:
|
||||
docs: |
|
||||
The evaluator engine type.
|
||||
|
||||
The unstable public API currently supports only LLM-as-a-judge evaluators.
|
||||
enum:
|
||||
- llm_as_judge
|
||||
|
||||
EvaluatorScope:
|
||||
docs: |
|
||||
Where an evaluator comes from.
|
||||
|
||||
- `project`: created in your project
|
||||
- `managed`: provided by Langfuse
|
||||
enum:
|
||||
- project
|
||||
- managed
|
||||
|
||||
EvaluationRuleTarget:
|
||||
docs: |
|
||||
The ingestion object type that should trigger evaluation runs.
|
||||
|
||||
Choose the target first, because it changes both the valid filter columns and the valid variable-mapping sources:
|
||||
- `observation` evaluates live-ingested observations such as generations, spans, and events.
|
||||
It supports mapping from `input`, `output`, and `metadata`.
|
||||
- `experiment` evaluates live experiment executions and can additionally map `expected_output`.
|
||||
It currently supports filtering by `datasetId`.
|
||||
Discover valid dataset IDs with `GET /api/public/v2/datasets`, then use the returned dataset `id` values in your filter.
|
||||
enum:
|
||||
- observation
|
||||
- experiment
|
||||
|
||||
EvaluationRuleStatus:
|
||||
docs: |
|
||||
Effective runtime status of the evaluation rule.
|
||||
|
||||
- `active`: enabled and currently runnable.
|
||||
- `inactive`: disabled by configuration.
|
||||
- `paused`: enabled, but Langfuse has blocked execution until the underlying issue is resolved.
|
||||
enum:
|
||||
- active
|
||||
- inactive
|
||||
- paused
|
||||
|
||||
EvaluationRuleMappingSource:
|
||||
docs: |
|
||||
Source field used to populate a prompt variable.
|
||||
|
||||
Use these values when mapping evaluator prompt variables to live data.
|
||||
|
||||
Target-specific rules:
|
||||
- `target=observation` supports `input`, `output`, and `metadata`
|
||||
- `target=experiment` supports `input`, `output`, `metadata`, and `expected_output`
|
||||
|
||||
Source semantics:
|
||||
- `input`: the observation or experiment input payload
|
||||
- `output`: the observation or experiment output payload
|
||||
- `metadata`: the metadata object for the target. Combine with `jsonPath` when you need one nested field instead of the whole object.
|
||||
- `expected_output`: the experiment item's expected output. Only valid for `target=experiment`.
|
||||
enum:
|
||||
- input
|
||||
- output
|
||||
- metadata
|
||||
- expected_output
|
||||
|
||||
EvaluatorModelConfig:
|
||||
docs: |
|
||||
Optional explicit model configuration for an evaluator.
|
||||
|
||||
If omitted, Langfuse uses the project's default evaluation model.
|
||||
If provided, the model must be available to the project when the evaluator or evaluation rule is enabled.
|
||||
|
||||
To discover valid configured `provider` values for a project, call `GET /api/public/llm-connections` and read the `provider` field from the returned connections.
|
||||
Use a `provider` value that matches one of the connections already configured in the same project.
|
||||
|
||||
Recovery guidance:
|
||||
- If evaluator creation returns `422` with `code=evaluator_preflight_failed`, either provide a valid explicit `modelConfig` here or configure the project's default evaluation model, then retry the same request.
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
docs: |
|
||||
Provider identifier to use for this evaluator, for example `openai` or `anthropic`.
|
||||
|
||||
To discover valid values for the current project, call `GET /api/public/llm-connections` and use one of the returned `provider` values.
|
||||
model:
|
||||
type: string
|
||||
docs: Model identifier exposed by the provider, for example `gpt-4.1-mini`.
|
||||
examples:
|
||||
- value:
|
||||
provider: openai
|
||||
model: gpt-4.1-mini
|
||||
|
||||
EvaluatorOutputDataType:
|
||||
docs: |
|
||||
Structured score type returned by an evaluator.
|
||||
|
||||
This controls the type of score value Langfuse stores for evaluation results:
|
||||
- `NUMERIC`: a numeric score such as `0.82`
|
||||
- `BOOLEAN`: a boolean score such as `true`
|
||||
- `CATEGORICAL`: one or more category labels from a fixed list
|
||||
enum:
|
||||
- NUMERIC
|
||||
- BOOLEAN
|
||||
- CATEGORICAL
|
||||
|
||||
EvaluatorOutputFieldDefinition:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
docs: Human-readable instructions for what the evaluator should return in this field.
|
||||
|
||||
EvaluatorOutputDefinition:
|
||||
docs: |
|
||||
Structured output definition to send when creating an evaluator.
|
||||
|
||||
Agent guidance:
|
||||
- `dataType` is required.
|
||||
- Do not send `version`; that is an internal storage detail and is not part of the public request contract.
|
||||
- For `NUMERIC` and `BOOLEAN`, provide `reasoning.description` and `score.description`.
|
||||
- For `CATEGORICAL`, also provide `score.categories` and `score.shouldAllowMultipleMatches`.
|
||||
discriminant: dataType
|
||||
union:
|
||||
NUMERIC:
|
||||
type: PublicNumericEvaluatorOutputDefinition
|
||||
BOOLEAN:
|
||||
type: PublicBooleanEvaluatorOutputDefinition
|
||||
CATEGORICAL:
|
||||
type: PublicCategoricalEvaluatorOutputDefinition
|
||||
examples:
|
||||
- name: Numeric
|
||||
value:
|
||||
dataType: NUMERIC
|
||||
reasoning:
|
||||
description: Explain why the answer is correct or incorrect.
|
||||
score:
|
||||
description: Return a score between 0 and 1.
|
||||
- name: Boolean
|
||||
value:
|
||||
dataType: BOOLEAN
|
||||
reasoning:
|
||||
description: Explain why the output satisfies the requirement.
|
||||
score:
|
||||
description: Return true if the output satisfies the requirement, otherwise false.
|
||||
- name: Categorical
|
||||
value:
|
||||
dataType: CATEGORICAL
|
||||
reasoning:
|
||||
description: Explain which category best fits the output.
|
||||
score:
|
||||
description: Choose the best category.
|
||||
categories:
|
||||
- correct
|
||||
- partially_correct
|
||||
- incorrect
|
||||
shouldAllowMultipleMatches: false
|
||||
|
||||
PublicNumericEvaluatorOutputDefinition:
|
||||
properties:
|
||||
dataType:
|
||||
type: EvaluatorOutputDataType
|
||||
docs: Always `NUMERIC`.
|
||||
reasoning:
|
||||
type: EvaluatorOutputFieldDefinition
|
||||
score:
|
||||
type: EvaluatorOutputFieldDefinition
|
||||
|
||||
PublicBooleanEvaluatorOutputDefinition:
|
||||
properties:
|
||||
dataType:
|
||||
type: EvaluatorOutputDataType
|
||||
docs: Always `BOOLEAN`.
|
||||
reasoning:
|
||||
type: EvaluatorOutputFieldDefinition
|
||||
score:
|
||||
type: EvaluatorOutputFieldDefinition
|
||||
|
||||
PublicCategoricalEvaluatorOutputScoreDefinition:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
categories:
|
||||
type: list<string>
|
||||
shouldAllowMultipleMatches:
|
||||
type: boolean
|
||||
|
||||
PublicCategoricalEvaluatorOutputDefinition:
|
||||
properties:
|
||||
dataType:
|
||||
type: EvaluatorOutputDataType
|
||||
docs: Always `CATEGORICAL`.
|
||||
reasoning:
|
||||
type: EvaluatorOutputFieldDefinition
|
||||
score:
|
||||
type: PublicCategoricalEvaluatorOutputScoreDefinition
|
||||
|
||||
PublicEvaluatorOutputDefinition:
|
||||
docs: |
|
||||
Evaluator output definition returned by the public API.
|
||||
|
||||
This response always includes `dataType` and never includes an internal output-definition `version`.
|
||||
Legacy stored evaluator definitions are normalized into this shape before they are returned.
|
||||
|
||||
Use this response shape when deciding how to interpret future evaluation scores:
|
||||
- `NUMERIC`: expect numeric score values
|
||||
- `BOOLEAN`: expect `true` / `false`
|
||||
- `CATEGORICAL`: expect one or more values from `score.categories`
|
||||
discriminant: dataType
|
||||
union:
|
||||
NUMERIC:
|
||||
type: PublicNumericEvaluatorOutputDefinition
|
||||
BOOLEAN:
|
||||
type: PublicBooleanEvaluatorOutputDefinition
|
||||
CATEGORICAL:
|
||||
type: PublicCategoricalEvaluatorOutputDefinition
|
||||
examples:
|
||||
- name: PublicNumeric
|
||||
value:
|
||||
dataType: NUMERIC
|
||||
reasoning:
|
||||
description: Explain why the answer is correct or incorrect.
|
||||
score:
|
||||
description: Return a score between 0 and 1.
|
||||
- name: PublicCategorical
|
||||
value:
|
||||
dataType: CATEGORICAL
|
||||
reasoning:
|
||||
description: Explain which label best fits the output.
|
||||
score:
|
||||
description: Choose the best label.
|
||||
categories:
|
||||
- correct
|
||||
- partially_correct
|
||||
- incorrect
|
||||
shouldAllowMultipleMatches: false
|
||||
|
||||
EvaluationRuleStringFilterOperator:
|
||||
enum:
|
||||
- name: Equals
|
||||
value: "="
|
||||
- name: Contains
|
||||
value: contains
|
||||
- name: DoesNotContain
|
||||
value: does not contain
|
||||
- name: StartsWith
|
||||
value: starts with
|
||||
- name: EndsWith
|
||||
value: ends with
|
||||
|
||||
EvaluationRuleNumberFilterOperator:
|
||||
enum:
|
||||
- name: Equals
|
||||
value: "="
|
||||
- name: GreaterThan
|
||||
value: ">"
|
||||
- name: LessThan
|
||||
value: "<"
|
||||
- name: GreaterThanOrEqual
|
||||
value: ">="
|
||||
- name: LessThanOrEqual
|
||||
value: "<="
|
||||
|
||||
EvaluationRuleOptionsFilterOperator:
|
||||
enum:
|
||||
- name: AnyOf
|
||||
value: any of
|
||||
- name: NoneOf
|
||||
value: none of
|
||||
|
||||
EvaluationRuleArrayOptionsFilterOperator:
|
||||
enum:
|
||||
- name: AnyOf
|
||||
value: any of
|
||||
- name: NoneOf
|
||||
value: none of
|
||||
- name: AllOf
|
||||
value: all of
|
||||
|
||||
EvaluationRuleBooleanFilterOperator:
|
||||
enum:
|
||||
- name: Equals
|
||||
value: "="
|
||||
- name: NotEquals
|
||||
value: "<>"
|
||||
|
||||
EvaluationRuleNullFilterOperator:
|
||||
enum:
|
||||
- name: IsNull
|
||||
value: is null
|
||||
- name: IsNotNull
|
||||
value: is not null
|
||||
|
||||
DateTimeEvaluationRuleFilter:
|
||||
properties:
|
||||
column:
|
||||
type: string
|
||||
docs: Column to filter on.
|
||||
operator:
|
||||
type: EvaluationRuleNumberFilterOperator
|
||||
docs: Comparison operator for datetime values.
|
||||
value:
|
||||
type: datetime
|
||||
docs: Datetime value to compare against.
|
||||
|
||||
StringEvaluationRuleFilter:
|
||||
properties:
|
||||
column:
|
||||
type: string
|
||||
docs: Column to filter on.
|
||||
operator:
|
||||
type: EvaluationRuleStringFilterOperator
|
||||
value:
|
||||
type: string
|
||||
|
||||
NumberEvaluationRuleFilter:
|
||||
properties:
|
||||
column:
|
||||
type: string
|
||||
docs: Column to filter on.
|
||||
operator:
|
||||
type: EvaluationRuleNumberFilterOperator
|
||||
value:
|
||||
type: double
|
||||
|
||||
StringOptionsEvaluationRuleFilter:
|
||||
properties:
|
||||
column:
|
||||
type: string
|
||||
docs: Column to filter on.
|
||||
operator:
|
||||
type: EvaluationRuleOptionsFilterOperator
|
||||
value:
|
||||
type: list<string>
|
||||
docs: One or more allowed string values.
|
||||
|
||||
ArrayOptionsEvaluationRuleFilter:
|
||||
properties:
|
||||
column:
|
||||
type: string
|
||||
docs: Column to filter on.
|
||||
operator:
|
||||
type: EvaluationRuleArrayOptionsFilterOperator
|
||||
value:
|
||||
type: list<string>
|
||||
docs: One or more array elements to match.
|
||||
|
||||
StringObjectEvaluationRuleFilter:
|
||||
properties:
|
||||
column:
|
||||
type: string
|
||||
docs: Object-valued column to filter on. In the unstable public API this is currently `metadata`.
|
||||
key:
|
||||
type: string
|
||||
docs: Top-level key inside the object-valued column to filter on.
|
||||
operator:
|
||||
type: EvaluationRuleStringFilterOperator
|
||||
value:
|
||||
type: string
|
||||
|
||||
NumberObjectEvaluationRuleFilter:
|
||||
properties:
|
||||
column:
|
||||
type: string
|
||||
docs: Object-valued column to filter on.
|
||||
key:
|
||||
type: string
|
||||
docs: Key inside the object-valued column to filter on.
|
||||
operator:
|
||||
type: EvaluationRuleNumberFilterOperator
|
||||
value:
|
||||
type: double
|
||||
|
||||
CategoryOptionsEvaluationRuleFilter:
|
||||
properties:
|
||||
column:
|
||||
type: string
|
||||
docs: Object-valued column to filter on.
|
||||
key:
|
||||
type: string
|
||||
docs: Key inside the object-valued column to filter on.
|
||||
operator:
|
||||
type: EvaluationRuleOptionsFilterOperator
|
||||
value:
|
||||
type: list<string>
|
||||
|
||||
BooleanEvaluationRuleFilter:
|
||||
properties:
|
||||
column:
|
||||
type: string
|
||||
docs: Column to filter on.
|
||||
operator:
|
||||
type: EvaluationRuleBooleanFilterOperator
|
||||
value:
|
||||
type: boolean
|
||||
|
||||
NullEvaluationRuleFilter:
|
||||
properties:
|
||||
column:
|
||||
type: string
|
||||
docs: Column to filter on. In the unstable public API this is currently `parentObservationId`.
|
||||
operator:
|
||||
type: EvaluationRuleNullFilterOperator
|
||||
value:
|
||||
type: optional<string>
|
||||
docs: Ignored placeholder value. Clients may omit it or send an empty string.
|
||||
|
||||
EvaluationRuleMapping:
|
||||
docs: |
|
||||
Maps one evaluator prompt variable to one source field from the target object.
|
||||
|
||||
How to build a valid mapping list:
|
||||
1. Create the evaluator or fetch it with `GET /evaluators/{id}`.
|
||||
2. Read the evaluator `variables` array.
|
||||
3. Add exactly one mapping object for each variable in that array.
|
||||
4. Use the variable name exactly as returned, without braces such as `{{` or `}}`.
|
||||
5. Choose a `source` that is valid for the selected `target`.
|
||||
|
||||
`jsonPath` is optional. Use it only when the selected source is a JSON object and you want to extract one nested field before inserting it into the evaluator prompt.
|
||||
|
||||
Recovery guidance:
|
||||
- `invalid_variable_mapping`: the variable name is unknown for this evaluator, or the selected `source` is not valid for the chosen `target`
|
||||
- `missing_variable_mapping`: one or more evaluator variables are not mapped yet
|
||||
- `duplicate_variable_mapping`: the same evaluator variable appears more than once
|
||||
- `invalid_json_path`: the JSONPath expression is malformed. Remove it or correct it.
|
||||
properties:
|
||||
variable:
|
||||
type: string
|
||||
docs: |
|
||||
Prompt variable name without braces.
|
||||
|
||||
Example: for the prompt `Judge {{input}} against {{output}}`, use `input` and `output`.
|
||||
source:
|
||||
type: EvaluationRuleMappingSource
|
||||
docs: |
|
||||
Source field that should populate the prompt variable.
|
||||
|
||||
Quick reference:
|
||||
- `target=observation`: `input`, `output`, `metadata`
|
||||
- `target=experiment`: `input`, `output`, `metadata`, `expected_output`
|
||||
jsonPath:
|
||||
type: optional<string>
|
||||
docs: |
|
||||
Optional JSONPath selector applied to the selected source before it is passed to the evaluator prompt.
|
||||
|
||||
Requirements:
|
||||
- Must start with `$`
|
||||
- Must be a syntactically valid JSONPath expression
|
||||
- Most useful with `source=metadata`
|
||||
examples:
|
||||
- name: BasicObservationMapping
|
||||
value:
|
||||
variable: input
|
||||
source: input
|
||||
- name: MetadataProjectionMapping
|
||||
value:
|
||||
variable: customer_tier
|
||||
source: metadata
|
||||
jsonPath: "$.customer.tier"
|
||||
- name: ExperimentExpectedOutputMapping
|
||||
value:
|
||||
variable: expected_output
|
||||
source: expected_output
|
||||
|
||||
EvaluationRuleFilter:
|
||||
docs: |
|
||||
One filter condition used to decide whether a live-ingested target should be evaluated.
|
||||
|
||||
An evaluation rule can include zero or more filter objects. All filters must be satisfied for the target to run.
|
||||
|
||||
How to build a valid filter object:
|
||||
- Pick the `target` first, because it changes the supported columns.
|
||||
- Pick the filter `type`. That determines which fields are required.
|
||||
- Use `key` only for object filters such as `metadata`.
|
||||
- Use the correct `value` shape for the chosen filter `type`.
|
||||
|
||||
Operator quick reference by filter `type`:
|
||||
- `string`: `"="`, `contains`, `does not contain`, `starts with`, `ends with`
|
||||
- `number`: `"="`, `">"`, `"<"`, `">="`, `"<="`
|
||||
- `datetime`: `"="`, `">"`, `"<"`, `">="`, `"<="`
|
||||
- `stringOptions`: `any of`, `none of`
|
||||
- `arrayOptions`: `any of`, `none of`, `all of`
|
||||
- `stringObject`: same operators as `string`
|
||||
- `null`: `is null`, `is not null`
|
||||
|
||||
Supported columns by target:
|
||||
- `target=observation`
|
||||
- `type`: `stringOptions`, operators `any of` / `none of`, values `GENERATION`, `SPAN`, `EVENT`
|
||||
- `name`: `stringOptions`, operators `any of` / `none of`
|
||||
- `environment`: `stringOptions`, operators `any of` / `none of`
|
||||
- `level`: `stringOptions`, operators `any of` / `none of`, values `DEBUG`, `DEFAULT`, `WARNING`, `ERROR`
|
||||
- `version`: `string`
|
||||
- `traceName`: `stringOptions`, operators `any of` / `none of`
|
||||
- `userId`: `string`
|
||||
- `sessionId`: `string`
|
||||
- `tags`: `arrayOptions`, operators `any of` / `none of` / `all of`
|
||||
- `metadata`: `stringObject` with `key`
|
||||
- `parentObservationId`: `null`, operators `is null` / `is not null`
|
||||
- `calledToolNames`: `arrayOptions`, operators `any of` / `none of` / `all of`
|
||||
- `toolCalls`: `number`
|
||||
- `target=experiment`
|
||||
- `datasetId`: `stringOptions`, operators `any of` / `none of`
|
||||
Use dataset `id` values from `GET /api/public/v2/datasets`, not dataset names.
|
||||
|
||||
Recovery guidance:
|
||||
- `invalid_filter_value` with `details.column` but no `invalidValues`: the selected `column` is not supported for the chosen `target`
|
||||
- `invalid_filter_value` with `details.invalidValues`: the selected values are not allowed for that column. Replace them with one of `details.allowedValues` when provided.
|
||||
- `invalid_filter_value` for `column=datasetId`: call `GET /api/public/v2/datasets`, then retry with dataset `id` values from that response.
|
||||
discriminant: type
|
||||
union:
|
||||
"datetime":
|
||||
type: DateTimeEvaluationRuleFilter
|
||||
"string":
|
||||
type: StringEvaluationRuleFilter
|
||||
"number":
|
||||
type: NumberEvaluationRuleFilter
|
||||
"stringOptions":
|
||||
type: StringOptionsEvaluationRuleFilter
|
||||
"categoryOptions":
|
||||
type: CategoryOptionsEvaluationRuleFilter
|
||||
"arrayOptions":
|
||||
type: ArrayOptionsEvaluationRuleFilter
|
||||
"stringObject":
|
||||
type: StringObjectEvaluationRuleFilter
|
||||
"numberObject":
|
||||
type: NumberObjectEvaluationRuleFilter
|
||||
"boolean":
|
||||
type: BooleanEvaluationRuleFilter
|
||||
"null":
|
||||
type: NullEvaluationRuleFilter
|
||||
examples:
|
||||
- name: ObservationTypeFilter
|
||||
value:
|
||||
type: stringOptions
|
||||
column: type
|
||||
operator: any of
|
||||
value:
|
||||
- GENERATION
|
||||
- name: ObservationMetadataFilter
|
||||
value:
|
||||
type: stringObject
|
||||
column: metadata
|
||||
key: customerTier
|
||||
operator: "="
|
||||
value: enterprise
|
||||
- name: ObservationRootOnlyFilter
|
||||
value:
|
||||
type: "null"
|
||||
column: parentObservationId
|
||||
operator: is null
|
||||
- name: ObservationTagsFilter
|
||||
value:
|
||||
type: arrayOptions
|
||||
column: tags
|
||||
operator: any of
|
||||
value:
|
||||
- production
|
||||
- name: ExperimentDatasetFilter
|
||||
value:
|
||||
type: stringOptions
|
||||
column: datasetId
|
||||
operator: any of
|
||||
value:
|
||||
- "550e8400-e29b-41d4-a716-446655440000"
|
||||
@@ -0,0 +1,269 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
types:
|
||||
PublicApiErrorCode:
|
||||
docs: |
|
||||
Machine-readable error code returned by the unstable evaluators API.
|
||||
|
||||
SDKs, CLIs, and agents should branch on `code` rather than parsing the human-readable `message`.
|
||||
The HTTP status still indicates the broad error class, while `code` gives the specific failure reason.
|
||||
enum:
|
||||
- authentication_failed
|
||||
- access_denied
|
||||
- invalid_request
|
||||
- invalid_query
|
||||
- invalid_body
|
||||
- invalid_filter_value
|
||||
- invalid_json_path
|
||||
- invalid_variable_mapping
|
||||
- missing_variable_mapping
|
||||
- duplicate_variable_mapping
|
||||
- resource_not_found
|
||||
- name_conflict
|
||||
- evaluator_preflight_failed
|
||||
- conflict
|
||||
- unprocessable_content
|
||||
- rate_limited
|
||||
- method_not_allowed
|
||||
- internal_error
|
||||
|
||||
PublicApiValidationIssue:
|
||||
docs: |
|
||||
One validation issue returned for malformed request bodies or query parameters.
|
||||
|
||||
This mirrors the most important parts of a Zod issue: a machine-readable `code`,
|
||||
a human-readable `message`, and a structured `path`.
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
docs: Machine-readable validation issue code emitted by the server validator.
|
||||
message:
|
||||
type: string
|
||||
docs: Human-readable explanation of the validation failure.
|
||||
path:
|
||||
type: list<unknown>
|
||||
docs: Path to the invalid field, for example `["mapping", 0, "jsonPath"]`.
|
||||
|
||||
PublicApiErrorDetails:
|
||||
docs: |
|
||||
Optional structured context attached to an unstable-evals error.
|
||||
|
||||
The populated fields depend on the error `code`:
|
||||
- request parsing failures populate `issues`
|
||||
- filter validation failures populate `field`, `column`, `invalidValues`, and `allowedValues`
|
||||
- variable mapping failures populate `field`, `variable`, or `variables`
|
||||
- JSONPath validation failures populate `field`, `variable`, and `value`
|
||||
- evaluator preflight failures populate `evaluatorName`, `provider`, and `model`
|
||||
- rate limiting populates `retryAfterSeconds`, `limit`, `remaining`, and `resetAt`
|
||||
properties:
|
||||
issues:
|
||||
type: optional<list<PublicApiValidationIssue>>
|
||||
docs: Validation issues for malformed request bodies or query parameters.
|
||||
field:
|
||||
type: optional<string>
|
||||
docs: Path-like reference to the failing field, for example `mapping[1].jsonPath`.
|
||||
column:
|
||||
type: optional<string>
|
||||
docs: Filter column that failed validation.
|
||||
invalidValues:
|
||||
type: optional<list<string>>
|
||||
docs: Unsupported values supplied by the caller.
|
||||
allowedValues:
|
||||
type: optional<list<string>>
|
||||
docs: Allowed values for the failing filter column.
|
||||
variable:
|
||||
type: optional<string>
|
||||
docs: Evaluator variable involved in the failure.
|
||||
variables:
|
||||
type: optional<list<string>>
|
||||
docs: Multiple evaluator variables involved in the failure, for example missing mappings.
|
||||
value:
|
||||
type: optional<string>
|
||||
docs: Raw invalid value supplied by the caller.
|
||||
evaluatorName:
|
||||
type: optional<string>
|
||||
docs: Evaluator name used during preflight validation.
|
||||
provider:
|
||||
type: optional<nullable<string>>
|
||||
docs: Provider resolved during evaluator preflight, if any.
|
||||
model:
|
||||
type: optional<nullable<string>>
|
||||
docs: Model resolved during evaluator preflight, if any.
|
||||
retryAfterSeconds:
|
||||
type: optional<integer>
|
||||
docs: Suggested retry delay for rate-limited requests.
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: Numeric limit associated with the failure, for example the active evaluation-rule cap or the current rate-limit window.
|
||||
remaining:
|
||||
type: optional<integer>
|
||||
docs: Remaining requests in the current rate-limit window.
|
||||
resetAt:
|
||||
type: optional<string>
|
||||
docs: ISO-8601 timestamp when the current rate-limit window resets.
|
||||
|
||||
PublicApiError:
|
||||
docs: |
|
||||
Standard error envelope for the unstable evaluators API.
|
||||
|
||||
Response handling guidance:
|
||||
- Use the HTTP status code for the broad class of failure.
|
||||
- Use `code` for precise branching in SDKs, CLIs, or agents.
|
||||
- Inspect `details` for field-level validation context such as invalid filter values, malformed JSONPath expressions, or missing variable mappings.
|
||||
- Retry only after fixing the specific issue described by `code` and `details`.
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
docs: Human-readable description of the failure.
|
||||
code:
|
||||
type: PublicApiErrorCode
|
||||
docs: Stable machine-readable error code.
|
||||
details:
|
||||
type: optional<PublicApiErrorDetails>
|
||||
docs: Optional structured error context. Inspect the populated fields based on `code`.
|
||||
examples:
|
||||
- name: InvalidFilterValue
|
||||
value:
|
||||
message: 'Filter column "type" contains unsupported value(s): INVALID'
|
||||
code: invalid_filter_value
|
||||
details:
|
||||
field: filter[0].value
|
||||
column: type
|
||||
invalidValues:
|
||||
- INVALID
|
||||
allowedValues:
|
||||
- GENERATION
|
||||
- SPAN
|
||||
- EVENT
|
||||
- name: InvalidExperimentDatasetFilterValue
|
||||
value:
|
||||
message: 'Filter column "datasetId" contains dataset id(s) that do not exist in this project: dataset-prod'
|
||||
code: invalid_filter_value
|
||||
details:
|
||||
field: filter[0].value
|
||||
column: datasetId
|
||||
invalidValues:
|
||||
- dataset-prod
|
||||
- name: EvaluatorPreflightFailed
|
||||
value:
|
||||
message: 'No valid LLM model found for evaluator "answer-correctness". No default model or custom model configured for project project_123'
|
||||
code: evaluator_preflight_failed
|
||||
details:
|
||||
evaluatorName: answer-correctness
|
||||
provider: null
|
||||
model: null
|
||||
- name: MissingVariableMapping
|
||||
value:
|
||||
message: "Missing mappings for evaluator variable(s): output"
|
||||
code: missing_variable_mapping
|
||||
details:
|
||||
field: mapping
|
||||
variables:
|
||||
- output
|
||||
- name: InvalidJsonPath
|
||||
value:
|
||||
message: 'Mapping for variable "customer_tier" has an invalid jsonPath "$[". JSONPath expressions must use balanced quotes, brackets, and parentheses.'
|
||||
code: invalid_json_path
|
||||
details:
|
||||
field: mapping[0].jsonPath
|
||||
variable: customer_tier
|
||||
value: "$["
|
||||
- name: NameConflict
|
||||
value:
|
||||
message: 'An evaluation rule named "answer-quality-live" already exists in this project. Use PATCH /api/public/unstable/evaluation-rules/erule_123 to update it instead of creating a duplicate.'
|
||||
code: name_conflict
|
||||
details:
|
||||
field: name
|
||||
- name: ActiveEvaluationRuleLimit
|
||||
value:
|
||||
message: This project already has the maximum number of active evaluation rules (50). Disable an existing active evaluation rule before enabling another one.
|
||||
code: conflict
|
||||
details:
|
||||
limit: 50
|
||||
- name: RateLimited
|
||||
value:
|
||||
message: Rate limit exceeded
|
||||
code: rate_limited
|
||||
details:
|
||||
retryAfterSeconds: 60
|
||||
limit: 1000
|
||||
remaining: 0
|
||||
resetAt: "2026-03-30T10:00:00.000Z"
|
||||
|
||||
errors:
|
||||
BadRequestError:
|
||||
docs: |
|
||||
Request parsing or validation failed.
|
||||
|
||||
Typical unstable-eval examples:
|
||||
- malformed request body or query parameters
|
||||
- unsupported filter value
|
||||
- invalid JSONPath selector
|
||||
- missing or duplicate variable mappings
|
||||
|
||||
Recovery guidance:
|
||||
- read `details.issues` for malformed bodies or queries
|
||||
- read `details.column`, `details.invalidValues`, and `details.allowedValues` for filter problems
|
||||
- for `details.column=datasetId`, call `GET /api/public/v2/datasets` and retry with dataset `id` values from that response
|
||||
- read `details.variable` or `details.variables` for mapping problems
|
||||
status-code: 400
|
||||
type: PublicApiError
|
||||
UnauthorizedError:
|
||||
docs: |
|
||||
Authentication failed.
|
||||
|
||||
Typical unstable-eval examples:
|
||||
- the API key or secret key is invalid
|
||||
- the request uses the wrong host or stale credentials
|
||||
status-code: 401
|
||||
type: PublicApiError
|
||||
AccessDeniedError:
|
||||
docs: |
|
||||
Authenticated, but the caller is not allowed to access the requested resource.
|
||||
|
||||
Typical unstable-eval examples:
|
||||
- using an organization-scoped key against project-scoped evaluator endpoints
|
||||
- using a valid key that lacks the required access level for this resource
|
||||
status-code: 403
|
||||
type: PublicApiError
|
||||
NotFoundError:
|
||||
docs: The requested evaluator or evaluation rule does not exist within the authorized project.
|
||||
status-code: 404
|
||||
type: PublicApiError
|
||||
MethodNotAllowedError:
|
||||
docs: The HTTP method is not supported for this endpoint.
|
||||
status-code: 405
|
||||
type: PublicApiError
|
||||
ConflictError:
|
||||
docs: |
|
||||
The request conflicts with existing state.
|
||||
|
||||
Typical unstable-eval examples:
|
||||
- evaluator version changed during concurrent creation
|
||||
- an evaluation rule name already exists in the project, so the caller should PATCH the existing resource instead
|
||||
- enabling another evaluation rule would exceed the project limit of 50 active evaluation rules
|
||||
- the underlying state changed between read and write operations, so the client should retry
|
||||
|
||||
Recovery guidance:
|
||||
- for `name_conflict`, PATCH the existing evaluation rule instead of creating another one
|
||||
- for the active-limit conflict, disable another active evaluation rule before enabling a new one
|
||||
status-code: 409
|
||||
type: PublicApiError
|
||||
UnprocessableContentError:
|
||||
docs: |
|
||||
The request is syntactically valid, but Langfuse cannot accept it in its current form.
|
||||
|
||||
The most important unstable-eval case is `code=evaluator_preflight_failed`, which means the evaluator cannot currently run with the resolved model configuration.
|
||||
|
||||
Recovery guidance:
|
||||
- configure the project's default evaluation model, or send a valid explicit evaluator `modelConfig`
|
||||
- once the model configuration is valid, retry the same request
|
||||
status-code: 422
|
||||
type: PublicApiError
|
||||
TooManyRequestsError:
|
||||
docs: The project is rate limited. Retry after the interval described in the response headers and error details.
|
||||
status-code: 429
|
||||
type: PublicApiError
|
||||
InternalServerError:
|
||||
docs: An unexpected server-side error occurred.
|
||||
status-code: 500
|
||||
type: PublicApiError
|
||||
@@ -0,0 +1,494 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
commons: ./commons.yml
|
||||
errors: ./errors.yml
|
||||
pagination: ../utils/pagination.yml
|
||||
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public/unstable
|
||||
endpoints:
|
||||
create:
|
||||
docs: |
|
||||
Create an evaluation rule.
|
||||
|
||||
An evaluation rule defines **what** incoming data should be evaluated and **how prompt variables should be populated** from that data.
|
||||
|
||||
Use this resource after choosing an evaluator from the evaluator endpoints.
|
||||
|
||||
Key rules:
|
||||
- `name` must be unique within the project for public evaluation rules
|
||||
- `target` must be `observation` or `experiment`
|
||||
- `evaluator.name` + `evaluator.scope` must identify an existing evaluator family returned by the evaluator endpoints
|
||||
- Langfuse resolves that family to its latest version before saving the evaluation rule
|
||||
- for `target=experiment`, use dataset `id` values from `GET /api/public/v2/datasets` when filtering by `datasetId`
|
||||
- every evaluator prompt variable must be mapped exactly once
|
||||
- `expected_output` mappings are only valid for `target=experiment`
|
||||
- if `enabled=true`, Langfuse validates that the referenced evaluator can currently run
|
||||
- at most 50 evaluation rules can be effectively active in one project at the same time
|
||||
|
||||
If an evaluation rule with the same `name` already exists in the project, the API returns `409`.
|
||||
In that case, update the existing resource with `PATCH /api/public/unstable/evaluation-rules/{evaluationRuleId}` instead of creating a second one.
|
||||
|
||||
If enabling this resource would exceed the 50-active limit, the API also returns `409`.
|
||||
In that case, disable or pause another active evaluation rule before enabling a new one.
|
||||
|
||||
Current scope:
|
||||
- evaluation rules are live-ingestion rules only
|
||||
- they do not trigger historical backfills
|
||||
|
||||
Recovery guidance:
|
||||
- `400 invalid_filter_value`: fix the filter `column` or `value` using `details.column`, `details.invalidValues`, and `details.allowedValues`
|
||||
- `400 invalid_filter_value` with `details.column=datasetId`: call `GET /api/public/v2/datasets`, then retry with dataset `id` values from that response
|
||||
- `400 missing_variable_mapping`: fetch the evaluator again and make sure every variable in `variables` appears exactly once in `mapping`
|
||||
- `400 duplicate_variable_mapping`: remove repeated mappings for the same variable
|
||||
- `400 invalid_variable_mapping`: switch to a valid `source` for the selected `target`, or fix the variable name
|
||||
- `400 invalid_json_path`: remove or correct the `jsonPath`
|
||||
- `422 evaluator_preflight_failed`: the selected evaluator cannot run with the resolved model configuration. Fix the evaluator/default model setup, then retry the create request.
|
||||
method: POST
|
||||
path: /evaluation-rules
|
||||
request: CreateEvaluationRuleRequest
|
||||
response: EvaluationRule
|
||||
errors:
|
||||
- errors.BadRequestError
|
||||
- errors.UnauthorizedError
|
||||
- errors.AccessDeniedError
|
||||
- errors.NotFoundError
|
||||
- errors.ConflictError
|
||||
- errors.MethodNotAllowedError
|
||||
- errors.UnprocessableContentError
|
||||
- errors.TooManyRequestsError
|
||||
- errors.InternalServerError
|
||||
examples:
|
||||
- name: CreateObservationEvaluationRule
|
||||
docs: Deploy an evaluator to score live generations only.
|
||||
request:
|
||||
name: answer-correctness-live
|
||||
evaluator:
|
||||
name: answer-correctness
|
||||
scope: project
|
||||
target: observation
|
||||
enabled: true
|
||||
sampling: 1
|
||||
filter:
|
||||
- type: stringOptions
|
||||
column: type
|
||||
operator: any of
|
||||
value:
|
||||
- GENERATION
|
||||
mapping:
|
||||
- variable: input
|
||||
source: input
|
||||
- variable: output
|
||||
source: output
|
||||
response:
|
||||
body:
|
||||
id: erule_123
|
||||
name: answer-correctness-live
|
||||
evaluator:
|
||||
id: evaltmpl_123
|
||||
name: answer-correctness
|
||||
scope: project
|
||||
target: observation
|
||||
enabled: true
|
||||
status: active
|
||||
pausedReason: null
|
||||
pausedMessage: null
|
||||
sampling: 1
|
||||
filter:
|
||||
- type: stringOptions
|
||||
column: type
|
||||
operator: any of
|
||||
value:
|
||||
- GENERATION
|
||||
mapping:
|
||||
- variable: input
|
||||
source: input
|
||||
- variable: output
|
||||
source: output
|
||||
createdAt: "2026-03-30T09:20:00.000Z"
|
||||
updatedAt: "2026-03-30T09:20:00.000Z"
|
||||
- name: CreateExperimentEvaluationRule
|
||||
docs: Deploy an evaluator to compare experiment outputs against expected outputs. Discover valid dataset IDs with `GET /api/public/v2/datasets` first.
|
||||
request:
|
||||
name: experiment-expected-output-match
|
||||
evaluator:
|
||||
name: expected-output-match
|
||||
scope: project
|
||||
target: experiment
|
||||
enabled: true
|
||||
sampling: 0.5
|
||||
filter:
|
||||
- type: stringOptions
|
||||
column: datasetId
|
||||
operator: any of
|
||||
value:
|
||||
- "550e8400-e29b-41d4-a716-446655440000"
|
||||
mapping:
|
||||
- variable: output
|
||||
source: output
|
||||
- variable: expected_output
|
||||
source: expected_output
|
||||
response:
|
||||
body:
|
||||
id: erule_456
|
||||
name: experiment-expected-output-match
|
||||
evaluator:
|
||||
id: evaltmpl_456
|
||||
name: expected-output-match
|
||||
scope: project
|
||||
target: experiment
|
||||
enabled: true
|
||||
status: active
|
||||
pausedReason: null
|
||||
pausedMessage: null
|
||||
sampling: 0.5
|
||||
filter:
|
||||
- type: stringOptions
|
||||
column: datasetId
|
||||
operator: any of
|
||||
value:
|
||||
- "550e8400-e29b-41d4-a716-446655440000"
|
||||
mapping:
|
||||
- variable: output
|
||||
source: output
|
||||
- variable: expected_output
|
||||
source: expected_output
|
||||
createdAt: "2026-03-30T09:30:00.000Z"
|
||||
updatedAt: "2026-03-30T09:30:00.000Z"
|
||||
|
||||
list:
|
||||
docs: |
|
||||
List evaluation rules in the authenticated project.
|
||||
|
||||
Each item describes one live evaluation rule and its effective runtime status.
|
||||
method: GET
|
||||
path: /evaluation-rules
|
||||
request:
|
||||
name: ListEvaluationRulesRequest
|
||||
query-parameters:
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: 1-based page number. Defaults to `1`.
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: Maximum number of items per page. Defaults to `50`.
|
||||
response: EvaluationRules
|
||||
errors:
|
||||
- errors.BadRequestError
|
||||
- errors.UnauthorizedError
|
||||
- errors.AccessDeniedError
|
||||
- errors.MethodNotAllowedError
|
||||
- errors.TooManyRequestsError
|
||||
- errors.InternalServerError
|
||||
|
||||
get:
|
||||
docs: |
|
||||
Get one evaluation rule by its identifier.
|
||||
|
||||
Use this endpoint to inspect the current evaluator, target, mapping, filters, and effective runtime status.
|
||||
method: GET
|
||||
path: /evaluation-rules/{evaluationRuleId}
|
||||
path-parameters:
|
||||
evaluationRuleId:
|
||||
type: string
|
||||
docs: Evaluation rule identifier returned by the evaluation rule endpoints.
|
||||
response: EvaluationRule
|
||||
errors:
|
||||
- errors.BadRequestError
|
||||
- errors.UnauthorizedError
|
||||
- errors.AccessDeniedError
|
||||
- errors.NotFoundError
|
||||
- errors.MethodNotAllowedError
|
||||
- errors.TooManyRequestsError
|
||||
- errors.InternalServerError
|
||||
|
||||
update:
|
||||
docs: |
|
||||
Update an evaluation rule.
|
||||
|
||||
Typical uses:
|
||||
- enable or disable live execution
|
||||
- switch to another evaluator
|
||||
- adjust sampling
|
||||
- change filters
|
||||
- update variable mappings
|
||||
|
||||
Important behavior:
|
||||
- provide only the fields you want to change
|
||||
- if you provide `evaluator`, Langfuse resolves that evaluator family to its latest version before saving
|
||||
- changing `target`, `filter`, or `mapping` must still produce a valid target-specific configuration
|
||||
- if you change `target`, also send a compatible `filter` and `mapping` in the same request unless the existing ones are still valid for the new target
|
||||
- if the resulting config is enabled, Langfuse re-validates that the selected evaluator can run
|
||||
- if the update would move a non-active evaluation rule into the active state and the project already has 50 active evaluation rules, the API returns `409`
|
||||
|
||||
Recovery guidance:
|
||||
- if the update fails with `missing_variable_mapping` or `invalid_variable_mapping` after changing `evaluator` or `target`, resend the request with a complete new `mapping`
|
||||
- if the update fails with `invalid_filter_value` after changing `target`, resend the request with a target-compatible `filter`
|
||||
method: PATCH
|
||||
path: /evaluation-rules/{evaluationRuleId}
|
||||
path-parameters:
|
||||
evaluationRuleId:
|
||||
type: string
|
||||
docs: Evaluation rule identifier.
|
||||
request: UpdateEvaluationRuleRequest
|
||||
response: EvaluationRule
|
||||
errors:
|
||||
- errors.BadRequestError
|
||||
- errors.UnauthorizedError
|
||||
- errors.AccessDeniedError
|
||||
- errors.NotFoundError
|
||||
- errors.MethodNotAllowedError
|
||||
- errors.UnprocessableContentError
|
||||
- errors.TooManyRequestsError
|
||||
- errors.InternalServerError
|
||||
|
||||
delete:
|
||||
docs: |
|
||||
Delete an evaluation rule.
|
||||
|
||||
This removes the live-ingestion rule only. It does not delete the referenced evaluator.
|
||||
method: DELETE
|
||||
path: /evaluation-rules/{evaluationRuleId}
|
||||
path-parameters:
|
||||
evaluationRuleId:
|
||||
type: string
|
||||
docs: Evaluation rule identifier.
|
||||
response: DeleteEvaluationRuleResponse
|
||||
errors:
|
||||
- errors.BadRequestError
|
||||
- errors.UnauthorizedError
|
||||
- errors.AccessDeniedError
|
||||
- errors.NotFoundError
|
||||
- errors.MethodNotAllowedError
|
||||
- errors.TooManyRequestsError
|
||||
- errors.InternalServerError
|
||||
|
||||
types:
|
||||
EvaluationRule:
|
||||
docs: |
|
||||
Live evaluation rule for incoming data.
|
||||
|
||||
An evaluation rule answers:
|
||||
- which evaluator should be used
|
||||
- which target objects should trigger scoring
|
||||
- how often scoring should run
|
||||
- which target fields should populate each evaluator variable
|
||||
- whether the deployment is active, inactive, or paused
|
||||
|
||||
Important status semantics:
|
||||
- `enabled` is the desired on/off setting from the client
|
||||
- `status` is the effective runtime state after Langfuse applies validation and blocking rules
|
||||
- `enabled=true` with `status=paused` means the rule should run, but Langfuse has paused it until the underlying problem is fixed
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
docs: Stable evaluation rule identifier.
|
||||
name:
|
||||
type: string
|
||||
docs: Human-readable deployment name. This is independent from the evaluator name.
|
||||
evaluator:
|
||||
type: EvaluationRuleEvaluator
|
||||
docs: |
|
||||
Evaluator currently used by this rule.
|
||||
|
||||
`name` and `scope` identify the evaluator family conceptually.
|
||||
`id` is the currently active evaluator version in that family.
|
||||
If you create a newer project version with the same evaluator name later, existing evaluation rules are moved to it automatically.
|
||||
target:
|
||||
type: commons.EvaluationRuleTarget
|
||||
docs: Target object type that should trigger scoring.
|
||||
enabled:
|
||||
type: boolean
|
||||
docs: Desired enabled state configured by the client.
|
||||
status:
|
||||
type: commons.EvaluationRuleStatus
|
||||
docs: Effective runtime status after Langfuse applies validation and blocking rules.
|
||||
pausedReason:
|
||||
type: nullable<string>
|
||||
docs: Machine-readable reason when `status=paused`, otherwise `null`.
|
||||
pausedMessage:
|
||||
type: nullable<string>
|
||||
docs: Human-readable explanation when `status=paused`, otherwise `null`.
|
||||
sampling:
|
||||
type: double
|
||||
docs: |
|
||||
Fraction of matching target objects that should be evaluated.
|
||||
|
||||
Must be greater than `0` and less than or equal to `1`.
|
||||
- `1` means evaluate every matching target.
|
||||
- `0.25` means evaluate approximately 25% of matching targets.
|
||||
filter:
|
||||
type: list<commons.EvaluationRuleFilter>
|
||||
docs: List of filter conditions used to decide whether a target should be evaluated.
|
||||
mapping:
|
||||
type: list<commons.EvaluationRuleMapping>
|
||||
docs: Variable mappings used to populate the evaluator prompt from the live target object.
|
||||
createdAt:
|
||||
type: datetime
|
||||
docs: Timestamp when the evaluation rule was created.
|
||||
updatedAt:
|
||||
type: datetime
|
||||
docs: Timestamp when the evaluation rule was last updated.
|
||||
examples:
|
||||
- value:
|
||||
id: erule_123
|
||||
name: answer-correctness-live
|
||||
evaluator:
|
||||
id: evaltmpl_123
|
||||
name: answer-correctness
|
||||
scope: project
|
||||
target: observation
|
||||
enabled: true
|
||||
status: active
|
||||
pausedReason: null
|
||||
pausedMessage: null
|
||||
sampling: 1
|
||||
filter:
|
||||
- type: stringOptions
|
||||
column: type
|
||||
operator: any of
|
||||
value:
|
||||
- GENERATION
|
||||
mapping:
|
||||
- variable: input
|
||||
source: input
|
||||
- variable: output
|
||||
source: output
|
||||
createdAt: "2026-03-30T09:20:00.000Z"
|
||||
updatedAt: "2026-03-30T09:20:00.000Z"
|
||||
|
||||
EvaluationRules:
|
||||
docs: Paginated list of evaluation rules.
|
||||
properties:
|
||||
data:
|
||||
type: list<EvaluationRule>
|
||||
docs: Evaluation rules in the current page.
|
||||
meta:
|
||||
type: pagination.MetaResponse
|
||||
docs: Standard pagination metadata.
|
||||
|
||||
CreateEvaluationRuleRequest:
|
||||
docs: |
|
||||
Request body for creating an evaluation rule.
|
||||
|
||||
Checklist for agents and SDK clients:
|
||||
- reference an existing evaluator family by `evaluator.name` and `evaluator.scope`
|
||||
- choose `target=observation` or `target=experiment`
|
||||
- if `target=experiment` and you want a dataset filter, call `GET /api/public/v2/datasets` first and use dataset `id` values in `filter[].value`
|
||||
- fetch or inspect the evaluator first, then provide a complete variable mapping for every evaluator variable listed in `variables`
|
||||
- optionally narrow execution with `filter`
|
||||
- set `enabled=true` only when you want live execution immediately
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
docs: Human-readable deployment name.
|
||||
evaluator:
|
||||
type: EvaluationRuleEvaluatorReference
|
||||
docs: |
|
||||
Evaluator family to use.
|
||||
|
||||
Use `name` and `scope` from the evaluator endpoints.
|
||||
Langfuse resolves that family to its latest version before saving the rule.
|
||||
target:
|
||||
type: commons.EvaluationRuleTarget
|
||||
docs: Target object type to evaluate.
|
||||
enabled:
|
||||
type: boolean
|
||||
docs: Whether the deployment should be active immediately after creation.
|
||||
sampling:
|
||||
type: optional<double>
|
||||
docs: Optional sampling fraction. Defaults to `1`.
|
||||
filter:
|
||||
type: optional<list<commons.EvaluationRuleFilter>>
|
||||
docs: |
|
||||
Optional filter list.
|
||||
|
||||
Omit or pass an empty list to evaluate all matching targets for the selected `target`.
|
||||
Each filter object must use a column that is valid for that `target`.
|
||||
For `target=experiment`, `column=datasetId` expects dataset `id` values from `GET /api/public/v2/datasets`, not dataset names.
|
||||
mapping:
|
||||
type: list<commons.EvaluationRuleMapping>
|
||||
docs: |
|
||||
Required variable mappings.
|
||||
|
||||
Every evaluator variable must appear exactly once.
|
||||
Build this list from the evaluator `variables` array returned by the evaluator endpoints.
|
||||
|
||||
UpdateEvaluationRuleRequest:
|
||||
docs: |
|
||||
Partial update body for an evaluation rule.
|
||||
|
||||
Provide only the fields you want to change.
|
||||
An empty body is rejected.
|
||||
|
||||
Practical guidance:
|
||||
- If you only want to rename the rule or change sampling, send just those fields.
|
||||
- If you change `evaluator`, send a fresh `mapping` unless you are certain the existing mapping still matches the evaluator variables.
|
||||
- If you change `target`, usually send both `filter` and `mapping` in the same request.
|
||||
- If you change an experiment `datasetId` filter, call `GET /api/public/v2/datasets` and use dataset `id` values from that response.
|
||||
properties:
|
||||
name:
|
||||
type: optional<string>
|
||||
docs: Updated deployment name.
|
||||
evaluator:
|
||||
type: optional<EvaluationRuleEvaluatorReference>
|
||||
docs: |
|
||||
Updated evaluator family.
|
||||
|
||||
Langfuse resolves the provided evaluator family to its latest version before saving the rule.
|
||||
target:
|
||||
type: optional<commons.EvaluationRuleTarget>
|
||||
docs: Updated target object type.
|
||||
enabled:
|
||||
type: optional<boolean>
|
||||
docs: Updated desired enabled state.
|
||||
sampling:
|
||||
type: optional<double>
|
||||
docs: Updated sampling fraction.
|
||||
filter:
|
||||
type: optional<list<commons.EvaluationRuleFilter>>
|
||||
docs: |
|
||||
Updated filter list.
|
||||
|
||||
For `target=experiment`, `column=datasetId` expects dataset `id` values from `GET /api/public/v2/datasets`, not dataset names.
|
||||
mapping:
|
||||
type: optional<list<commons.EvaluationRuleMapping>>
|
||||
docs: Updated variable mappings.
|
||||
|
||||
DeleteEvaluationRuleResponse:
|
||||
docs: Confirmation response returned after successful deletion.
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
docs: Always `Evaluation rule successfully deleted`.
|
||||
|
||||
EvaluationRuleEvaluatorReference:
|
||||
docs: |
|
||||
Evaluator family reference used when creating or updating an evaluation rule.
|
||||
|
||||
`name` and `scope` are enough to identify the evaluator family in the authenticated project context.
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
docs: Evaluator family name.
|
||||
scope:
|
||||
type: commons.EvaluatorScope
|
||||
docs: Whether the evaluator family is project-owned or Langfuse-managed.
|
||||
|
||||
EvaluationRuleEvaluator:
|
||||
docs: |
|
||||
Resolved evaluator currently used by the evaluation rule.
|
||||
|
||||
`id` is the exact active evaluator version.
|
||||
`name` and `scope` identify the evaluator family conceptually.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
docs: Identifier of the exact evaluator version currently used by the rule.
|
||||
name:
|
||||
type: string
|
||||
docs: Evaluator family name.
|
||||
scope:
|
||||
type: commons.EvaluatorScope
|
||||
docs: Whether the evaluator family is project-owned or Langfuse-managed.
|
||||
@@ -0,0 +1,250 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json
|
||||
imports:
|
||||
commons: ./commons.yml
|
||||
errors: ./errors.yml
|
||||
pagination: ../utils/pagination.yml
|
||||
|
||||
service:
|
||||
auth: true
|
||||
base-path: /api/public/unstable
|
||||
endpoints:
|
||||
create:
|
||||
docs: |
|
||||
Create an evaluator in the authenticated project.
|
||||
|
||||
Use evaluators to define **how** Langfuse should score data: the prompt, the expected structured output, and the optional model configuration.
|
||||
|
||||
Naming behavior:
|
||||
- If this is a new evaluator name in your project, Langfuse creates version `1`.
|
||||
- If the name already exists in your project, Langfuse creates the next version and returns it.
|
||||
- When a new project version is created, existing evaluation rules in that project automatically move to the newest version for that evaluator name.
|
||||
|
||||
Recommended workflow:
|
||||
1. Create the evaluator.
|
||||
2. Read the returned `variables` array.
|
||||
3. Read the returned `outputDefinition.dataType` so the client knows whether future scores will be numeric, boolean, or categorical.
|
||||
4. Create one or more evaluation rules that reference the returned evaluator family using `name` and `scope`.
|
||||
|
||||
Recovery guidance:
|
||||
- `422` with `code=evaluator_preflight_failed`: the evaluator cannot run with the resolved model configuration. Add a valid explicit `modelConfig`, or configure the project's default evaluation model, then retry the same request.
|
||||
- `400` with `code=invalid_body`: the request shape is malformed. Use the structured `details.issues` array to fix the specific fields and retry.
|
||||
- `400` with `code=invalid_body` on `outputDefinition`: send `dataType`, `reasoning.description`, and `score.description`. Do not send `version`; it is not part of the public request shape.
|
||||
|
||||
Unstable API note:
|
||||
- This surface may evolve while the underlying evaluation data model is being redesigned.
|
||||
method: POST
|
||||
path: /evaluators
|
||||
request: CreateEvaluatorRequest
|
||||
response: Evaluator
|
||||
errors:
|
||||
- errors.BadRequestError
|
||||
- errors.UnauthorizedError
|
||||
- errors.AccessDeniedError
|
||||
- errors.MethodNotAllowedError
|
||||
- errors.ConflictError
|
||||
- errors.UnprocessableContentError
|
||||
- errors.TooManyRequestsError
|
||||
- errors.InternalServerError
|
||||
examples:
|
||||
- name: CreateEvaluatorVersion
|
||||
docs: Create a new version of an evaluator named `answer-correctness`.
|
||||
request:
|
||||
name: answer-correctness
|
||||
prompt: |
|
||||
You are grading an answer.
|
||||
|
||||
Input:
|
||||
{{input}}
|
||||
|
||||
Output:
|
||||
{{output}}
|
||||
|
||||
Return a score between 0 and 1.
|
||||
outputDefinition:
|
||||
dataType: NUMERIC
|
||||
reasoning:
|
||||
description: Explain why the score was assigned.
|
||||
score:
|
||||
description: Correctness score between 0 and 1.
|
||||
modelConfig:
|
||||
provider: openai
|
||||
model: gpt-4.1-mini
|
||||
response:
|
||||
body:
|
||||
id: evaltmpl_123
|
||||
name: answer-correctness
|
||||
version: 2
|
||||
scope: project
|
||||
type: llm_as_judge
|
||||
prompt: |
|
||||
You are grading an answer.
|
||||
|
||||
Input:
|
||||
{{input}}
|
||||
|
||||
Output:
|
||||
{{output}}
|
||||
|
||||
Return a score between 0 and 1.
|
||||
variables:
|
||||
- input
|
||||
- output
|
||||
outputDefinition:
|
||||
dataType: NUMERIC
|
||||
reasoning:
|
||||
description: Explain why the score was assigned.
|
||||
score:
|
||||
description: Correctness score between 0 and 1.
|
||||
modelConfig:
|
||||
provider: openai
|
||||
model: gpt-4.1-mini
|
||||
evaluationRuleCount: 0
|
||||
createdAt: "2026-03-30T09:00:00.000Z"
|
||||
updatedAt: "2026-03-30T09:00:00.000Z"
|
||||
|
||||
list:
|
||||
docs: |
|
||||
List the evaluators available to the authenticated project.
|
||||
|
||||
Important behavior:
|
||||
- This endpoint returns the latest version of each available evaluator.
|
||||
- Results can include evaluators from your project and Langfuse-managed evaluators.
|
||||
- If the same evaluator name exists in both places, both are returned as separate items with different `scope` values.
|
||||
method: GET
|
||||
path: /evaluators
|
||||
request:
|
||||
name: ListEvaluatorsRequest
|
||||
query-parameters:
|
||||
page:
|
||||
type: optional<integer>
|
||||
docs: 1-based page number. Defaults to `1`.
|
||||
limit:
|
||||
type: optional<integer>
|
||||
docs: Maximum number of items per page. Defaults to `50`.
|
||||
response: Evaluators
|
||||
errors:
|
||||
- errors.BadRequestError
|
||||
- errors.UnauthorizedError
|
||||
- errors.AccessDeniedError
|
||||
- errors.MethodNotAllowedError
|
||||
- errors.TooManyRequestsError
|
||||
- errors.InternalServerError
|
||||
|
||||
get:
|
||||
docs: |
|
||||
Get one evaluator by `id`.
|
||||
|
||||
Use this endpoint when you want the prompt, output definition, model configuration, and derived variables for the evaluator you plan to use in an evaluation rule.
|
||||
method: GET
|
||||
path: /evaluators/{evaluatorId}
|
||||
path-parameters:
|
||||
evaluatorId:
|
||||
type: string
|
||||
docs: Evaluator identifier returned by the evaluator endpoints.
|
||||
response: Evaluator
|
||||
errors:
|
||||
- errors.BadRequestError
|
||||
- errors.UnauthorizedError
|
||||
- errors.AccessDeniedError
|
||||
- errors.NotFoundError
|
||||
- errors.MethodNotAllowedError
|
||||
- errors.TooManyRequestsError
|
||||
- errors.InternalServerError
|
||||
|
||||
types:
|
||||
Evaluator:
|
||||
docs: |
|
||||
One evaluator that can be used for scoring.
|
||||
|
||||
An evaluator describes **how** to score data:
|
||||
- prompt
|
||||
- extracted prompt variables
|
||||
- output schema
|
||||
- optional explicit model configuration
|
||||
|
||||
It does not define **which** live objects are evaluated. That is the job of `evaluation-rules`.
|
||||
|
||||
For agent clients, the most important fields are:
|
||||
- `variables`: use these exact names when building the evaluation-rule `mapping` array
|
||||
- `outputDefinition`: tells you the expected score type and the evaluator's response instructions
|
||||
- `modelConfig`: tells you whether the evaluator uses the project default model (`null`) or an explicit provider/model
|
||||
|
||||
Versioning behavior:
|
||||
- `GET /evaluators` returns the latest version of each available evaluator.
|
||||
- `GET /evaluators/{id}` can return an older version.
|
||||
- Evaluation rules always run against the latest version for the selected evaluator name within the same source (`project` or `managed`).
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
docs: Identifier of this evaluator.
|
||||
name:
|
||||
type: string
|
||||
docs: Evaluator name.
|
||||
version:
|
||||
type: integer
|
||||
docs: Version number of this evaluator.
|
||||
scope:
|
||||
type: commons.EvaluatorScope
|
||||
docs: "Where this evaluator comes from: your project or Langfuse-managed defaults."
|
||||
type:
|
||||
type: commons.EvaluatorType
|
||||
docs: Evaluator engine type. Currently always `llm_as_judge`.
|
||||
prompt:
|
||||
type: string
|
||||
docs: Prompt template used during evaluation.
|
||||
variables:
|
||||
type: list<string>
|
||||
docs: |
|
||||
Variables extracted from the evaluator prompt.
|
||||
|
||||
Every variable in this list must be mapped exactly once when creating an evaluation rule.
|
||||
outputDefinition:
|
||||
type: commons.PublicEvaluatorOutputDefinition
|
||||
docs: |
|
||||
Structured output schema returned by this evaluator.
|
||||
|
||||
Responses always include `dataType` and omit the internal output-definition `version`.
|
||||
Use `dataType` to decide how future scores should be interpreted.
|
||||
modelConfig:
|
||||
type: nullable<commons.EvaluatorModelConfig>
|
||||
docs: Explicit model configuration, or `null` when the project default evaluation model is used.
|
||||
evaluationRuleCount:
|
||||
type: integer
|
||||
docs: Number of evaluation rules in the project that currently use this evaluator version.
|
||||
createdAt:
|
||||
type: datetime
|
||||
docs: Timestamp when this evaluator was created.
|
||||
updatedAt:
|
||||
type: datetime
|
||||
docs: Timestamp when this evaluator was last updated.
|
||||
|
||||
Evaluators:
|
||||
properties:
|
||||
data:
|
||||
type: list<Evaluator>
|
||||
meta:
|
||||
type: pagination.MetaResponse
|
||||
|
||||
CreateEvaluatorRequest:
|
||||
docs: |
|
||||
Request body for creating an evaluator.
|
||||
|
||||
If the same `name` already exists in your project, Langfuse creates the next version and returns it.
|
||||
Existing evaluation rules in the same project are then moved to that new latest version automatically.
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
docs: Evaluator name within the authenticated project.
|
||||
prompt:
|
||||
type: string
|
||||
docs: Prompt template used by the evaluator.
|
||||
outputDefinition:
|
||||
type: commons.EvaluatorOutputDefinition
|
||||
docs: |
|
||||
Structured output schema the evaluator must return.
|
||||
|
||||
Always send `dataType`.
|
||||
Do not send `version`; it is an internal storage detail and not part of the public request contract.
|
||||
modelConfig:
|
||||
type: optional<nullable<commons.EvaluatorModelConfig>>
|
||||
docs: Optional explicit model configuration. Omit or set to `null` to use the project default evaluation model.
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.167.2",
|
||||
"version": "3.170.0",
|
||||
"author": "engineering@langfuse.com",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -108,7 +108,8 @@
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"glob": "^10.5.0",
|
||||
"qs": "6.14.1"
|
||||
"qs": "6.14.1",
|
||||
"path-to-regexp@0.1.12": "0.1.13"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
|
||||
|
||||
@@ -95,6 +95,7 @@ export default [
|
||||
},
|
||||
],
|
||||
"react/jsx-key": ["error", { warnOnDuplicates: true }],
|
||||
"react/no-unused-prop-types": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
],
|
||||
},
|
||||
|
||||
@@ -25,11 +25,15 @@ Use root [AGENTS.md](../../AGENTS.md) for monorepo-level rules.
|
||||
- Main exports: `src/index.ts`
|
||||
- DB clients and types: `src/db.ts`
|
||||
- Server exports: `src/server/index.ts`
|
||||
- Server cache utilities: `src/server/cache/*`
|
||||
- Domain model types: `src/domain/*`
|
||||
- Repository layer: `src/server/repositories/*`
|
||||
- Queue payload schemas: `src/server/queues.ts`
|
||||
- Queue helpers: `src/server/redis/*`
|
||||
- Postgres schema: `prisma/schema.prisma`
|
||||
- For unstable public eval APIs, the public `evaluatorId` is currently the
|
||||
exact `EvalTemplate.id`. Latest-version family grouping is derived from
|
||||
`(projectId, name)` rather than stored on extra evaluator identity fields.
|
||||
- Prisma migrations: `prisma/migrations/*`
|
||||
- ClickHouse migrations: `clickhouse/migrations/{clustered,unclustered}/*`
|
||||
- Seeder and support scripts: `scripts/seeder/*`, `clickhouse/scripts/*`
|
||||
|
||||
@@ -372,7 +372,8 @@ CREATE TABLE IF NOT EXISTS events_core
|
||||
INDEX idx_session_id session_id TYPE bloom_filter(0.01) GRANULARITY 1,
|
||||
INDEX idx_created_at created_at TYPE minmax GRANULARITY 1,
|
||||
INDEX idx_updated_at updated_at TYPE minmax GRANULARITY 1,
|
||||
INDEX idx_provided_model_name provided_model_name TYPE bloom_filter(0.01) GRANULARITY 2
|
||||
INDEX idx_provided_model_name provided_model_name TYPE bloom_filter(0.01) GRANULARITY 2,
|
||||
INDEX idx_experiment_id experiment_id TYPE bloom_filter(0.01) GRANULARITY 1
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(event_ts, is_deleted)
|
||||
PARTITION BY toYYYYMM(start_time)
|
||||
@@ -456,6 +457,52 @@ SELECT
|
||||
is_deleted
|
||||
FROM events_full;
|
||||
|
||||
-- Diagnostic table to track event size distributions across projects.
|
||||
-- Every insert (including updates) produces a row — no deduplication.
|
||||
-- See LFE-9402 for context.
|
||||
CREATE TABLE IF NOT EXISTS ingestion_size_stats (
|
||||
project_id String,
|
||||
trace_id String,
|
||||
span_id String,
|
||||
created_at DateTime64(3),
|
||||
input_size UInt64,
|
||||
output_size UInt64,
|
||||
metadata_size UInt64,
|
||||
total_size UInt64
|
||||
) ENGINE = MergeTree
|
||||
PRIMARY KEY (toStartOfHour(created_at), project_id)
|
||||
ORDER BY (toStartOfHour(created_at), project_id, trace_id, span_id, created_at);
|
||||
|
||||
-- MV: observations -> ingestion_size_stats
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS ingestion_size_stats_observations_mv
|
||||
TO ingestion_size_stats AS
|
||||
SELECT
|
||||
project_id,
|
||||
trace_id,
|
||||
id AS span_id,
|
||||
created_at,
|
||||
length(coalesce(input, '')) AS input_size,
|
||||
length(coalesce(output, '')) AS output_size,
|
||||
arraySum(arrayMap(k -> length(k), mapKeys(metadata)))
|
||||
+ arraySum(arrayMap(v -> length(v), mapValues(metadata))) AS metadata_size,
|
||||
byteSize(*) AS total_size
|
||||
FROM observations;
|
||||
|
||||
-- MV: traces -> ingestion_size_stats
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS ingestion_size_stats_traces_mv
|
||||
TO ingestion_size_stats AS
|
||||
SELECT
|
||||
project_id,
|
||||
id AS trace_id,
|
||||
concat('t-', id) AS span_id,
|
||||
created_at,
|
||||
length(coalesce(input, '')) AS input_size,
|
||||
length(coalesce(output, '')) AS output_size,
|
||||
arraySum(arrayMap(k -> length(k), mapKeys(metadata)))
|
||||
+ arraySum(arrayMap(v -> length(v), mapValues(metadata))) AS metadata_size,
|
||||
byteSize(*) AS total_size
|
||||
FROM traces;
|
||||
|
||||
CREATE VIEW analytics_events_core AS
|
||||
SELECT
|
||||
project_id,
|
||||
|
||||
@@ -100,20 +100,23 @@
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.10",
|
||||
"bullmq": "^5.73.5",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.65.0",
|
||||
"dd-trace": "5.97.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"exponential-backoff": "^3.1.2",
|
||||
"ioredis": "^5.8.2",
|
||||
"ioredis": "^5.10.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"jsonpath-plus": "10.3.0",
|
||||
"langchain": "^1.3.0",
|
||||
"langfuse-langchain": "3.38.20",
|
||||
"lodash": "^4.18.1",
|
||||
"lossless-json": "^4.1.1",
|
||||
"lru-cache": "^11.2.7",
|
||||
"next-auth": "^4.24.13",
|
||||
"nodemailer": "^7.0.11",
|
||||
"oci-common": "^2.125.0",
|
||||
"oci-objectstorage": "^2.125.0",
|
||||
"safe-regex2": "^5.0.0",
|
||||
"undici": "^7.24.6",
|
||||
"uuid": "^9.0.1",
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "datasets" ADD COLUMN "remote_experiment_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -585,6 +585,7 @@ model Dataset {
|
||||
metadata Json?
|
||||
remoteExperimentUrl String? @map("remote_experiment_url")
|
||||
remoteExperimentPayload Json? @map("remote_experiment_payload")
|
||||
remoteExperimentEnabled Boolean @default(true) @map("remote_experiment_enabled")
|
||||
inputSchema Json? @map("input_schema") @db.Json
|
||||
expectedOutputSchema Json? @map("expected_output_schema") @db.Json
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
@@ -915,9 +916,9 @@ model EvalTemplate {
|
||||
projectId String? @map("project_id")
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String
|
||||
version Int
|
||||
prompt String
|
||||
name String
|
||||
version Int
|
||||
prompt String
|
||||
|
||||
partner String? // e.g. "ragas", describes the partner that created the template
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ async function main() {
|
||||
name: "Demo User",
|
||||
email: "demo@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
featureFlags: ["experimentsV4Enabled"],
|
||||
},
|
||||
create: {
|
||||
id: seedUserId1,
|
||||
@@ -63,7 +62,6 @@ async function main() {
|
||||
email: "demo@langfuse.com",
|
||||
password: await hash("password", 12),
|
||||
image: "https://static.langfuse.com/langfuse-dev%2Fexample-avatar.png",
|
||||
featureFlags: ["experimentsV4Enabled"],
|
||||
},
|
||||
});
|
||||
const user2 = await prisma.user.upsert({
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.167.2";
|
||||
export const VERSION = "v3.170.0";
|
||||
|
||||
@@ -116,6 +116,7 @@ export const EventsObservationSchema = ObservationSchema.extend({
|
||||
userId: z.string().nullable(),
|
||||
sessionId: z.string().nullable(),
|
||||
traceName: z.string().nullable(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
bookmarked: z.boolean().optional(),
|
||||
public: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,13 @@ export const ScoreConfigCategory = z.object({
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
/** Input-only schema for score config names. Use at API/tRPC boundaries, not for DB reads. */
|
||||
export const ScoreConfigNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(35)
|
||||
.regex(/^[\p{L}\p{N}_ .()-]+$/u, "Name contains invalid characters");
|
||||
|
||||
// Numeric config fields
|
||||
export const NumericConfigFields = z.object({
|
||||
maxValue: z.number().nullish(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import z from "zod";
|
||||
export enum TableViewPresetTableName {
|
||||
Traces = "traces",
|
||||
Observations = "observations",
|
||||
ObservationsEvents = "observations-events",
|
||||
Scores = "scores",
|
||||
Sessions = "sessions",
|
||||
SessionDetail = "session-detail",
|
||||
@@ -13,8 +14,7 @@ export enum TableViewPresetTableName {
|
||||
ExperimentItems = "experiment-items",
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const TableViewPresetDomainSchema = z.object({
|
||||
export const TableViewPresetDomainSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string().nullable(),
|
||||
createdAt: z.date(),
|
||||
@@ -25,7 +25,7 @@ const TableViewPresetDomainSchema = z.object({
|
||||
filters: z.array(singleFilter),
|
||||
columnOrder: z.array(z.string()),
|
||||
columnVisibility: z.record(z.string(), z.boolean()),
|
||||
searchQuery: z.string().optional(),
|
||||
searchQuery: z.string().nullable(),
|
||||
orderBy: orderBy,
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,18 @@ const EnvSchema = z.object({
|
||||
.optional(),
|
||||
LANGFUSE_CACHE_MODEL_MATCH_ENABLED: z.enum(["true", "false"]).default("true"),
|
||||
LANGFUSE_CACHE_MODEL_MATCH_TTL_SECONDS: z.coerce.number().default(86400), // 24 hours
|
||||
LANGFUSE_LOCAL_CACHE_MODEL_MATCH_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_LOCAL_CACHE_MODEL_MATCH_TTL_MS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(10_000),
|
||||
LANGFUSE_LOCAL_CACHE_MODEL_MATCH_MAX: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(20_000),
|
||||
LANGFUSE_CACHE_PROMPT_ENABLED: z.enum(["true", "false"]).default("true"),
|
||||
LANGFUSE_CACHE_PROMPT_TTL_SECONDS: z.coerce.number().default(3600), // 1h
|
||||
CLICKHOUSE_URL: z.string().url(),
|
||||
@@ -179,6 +191,21 @@ const EnvSchema = z.object({
|
||||
.default("true"),
|
||||
LANGFUSE_USE_GOOGLE_CLOUD_STORAGE: z.enum(["true", "false"]).default("false"),
|
||||
LANGFUSE_GOOGLE_CLOUD_STORAGE_CREDENTIALS: z.string().optional(),
|
||||
LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
LANGFUSE_OCI_AUTH_TYPE: z
|
||||
.enum([
|
||||
"workload_identity",
|
||||
"instance_principal",
|
||||
"resource_principal",
|
||||
"oci_profile",
|
||||
"session_token",
|
||||
])
|
||||
.optional(),
|
||||
LANGFUSE_OCI_CONFIG_FILE: z.string().optional(),
|
||||
LANGFUSE_OCI_CONFIG_PROFILE: z.string().optional(),
|
||||
NODE_EXTRA_CA_CERTS: z.string().optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
|
||||
LANGFUSE_ENABLE_BLOB_STORAGE_FILE_LOG: z
|
||||
|
||||
@@ -324,4 +324,10 @@ export const eventsTableCols: ColumnDefinition[] = [
|
||||
internal: "length(e.tool_calls)",
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
name: "Is Experiment Item Root Span",
|
||||
id: "isExperimentItemRootSpan",
|
||||
type: "boolean",
|
||||
internal: "e.experiment_item_root_span_id = e.span_id",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -22,8 +22,22 @@ export type MappingError = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type JsonPathMissInfo = {
|
||||
sourceField: SourceField;
|
||||
jsonPath: string;
|
||||
mappingKey: string | null;
|
||||
};
|
||||
|
||||
export type JsonPathErrorInfo = JsonPathMissInfo & { message: string };
|
||||
|
||||
export type FieldMappingResult = {
|
||||
value: unknown;
|
||||
misses: JsonPathMissInfo[];
|
||||
errors: JsonPathErrorInfo[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Test if a JSON path is valid against the given data
|
||||
* Test if a JSONPath is valid against the given data
|
||||
*/
|
||||
export function testJsonPath(props: { jsonPath: string; data: unknown }): {
|
||||
success: boolean;
|
||||
@@ -43,7 +57,7 @@ export function testJsonPath(props: { jsonPath: string; data: unknown }): {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a JSON path against the given data and return the result
|
||||
* Evaluate a JSONPath against the given data and return the result
|
||||
*/
|
||||
export function evaluateJsonPath(data: unknown, jsonPath: string): unknown {
|
||||
const parsed = typeof data === "string" ? parseJsonPrioritised(data) : data;
|
||||
@@ -51,13 +65,14 @@ export function evaluateJsonPath(data: unknown, jsonPath: string): unknown {
|
||||
path: jsonPath,
|
||||
json: parsed as string | object,
|
||||
wrap: false,
|
||||
eval: false,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a JSON path (starts with $)
|
||||
* Check if a value is a JSONPath (starts with $)
|
||||
*/
|
||||
export function isJsonPath(value: string): boolean {
|
||||
return value.startsWith("$");
|
||||
@@ -87,93 +102,116 @@ export function applyFieldMappingConfig(props: {
|
||||
observation: ObservationData;
|
||||
config: FieldMappingConfig;
|
||||
defaultSourceField: SourceField;
|
||||
onJsonPathMiss?: (info: {
|
||||
sourceField: SourceField;
|
||||
jsonPath: string;
|
||||
mappingKey: string | null;
|
||||
}) => void;
|
||||
}): unknown {
|
||||
const { observation, config, defaultSourceField, onJsonPathMiss } = props;
|
||||
}): FieldMappingResult {
|
||||
const { observation, config, defaultSourceField } = props;
|
||||
const misses: JsonPathMissInfo[] = [];
|
||||
const errors: JsonPathErrorInfo[] = [];
|
||||
|
||||
const safeEvaluate = (
|
||||
sourceField: SourceField,
|
||||
jsonPath: string,
|
||||
mappingKey: string | null,
|
||||
): { success: true; value: unknown } | { success: false } => {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
value: evaluateJsonPath(observation[sourceField], jsonPath),
|
||||
};
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
sourceField,
|
||||
jsonPath,
|
||||
mappingKey,
|
||||
message: error instanceof Error ? error.message : "Invalid JSONPath",
|
||||
});
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
const withValue = (value: unknown): FieldMappingResult => ({
|
||||
value,
|
||||
misses,
|
||||
errors,
|
||||
});
|
||||
|
||||
switch (config.mode) {
|
||||
case "full":
|
||||
// Return the full source field
|
||||
return observation[defaultSourceField];
|
||||
return withValue(observation[defaultSourceField]);
|
||||
|
||||
case "none":
|
||||
// Return null (will be written as Prisma.DbNull)
|
||||
return null;
|
||||
// null is written as Prisma.DbNull
|
||||
return withValue(null);
|
||||
|
||||
case "custom":
|
||||
if (!config.custom) {
|
||||
return observation[defaultSourceField];
|
||||
}
|
||||
case "custom": {
|
||||
if (!config.custom) return withValue(observation[defaultSourceField]);
|
||||
|
||||
if (config.custom.type === "root") {
|
||||
// Root mode: extract single value using JSON path
|
||||
const rootConfig = config.custom.rootConfig;
|
||||
if (!rootConfig) {
|
||||
return observation[defaultSourceField];
|
||||
}
|
||||
if (!rootConfig) return withValue(observation[defaultSourceField]);
|
||||
|
||||
const sourceData = observation[rootConfig.sourceField];
|
||||
const result = evaluateJsonPath(sourceData, rootConfig.jsonPath);
|
||||
if (result === undefined && onJsonPathMiss) {
|
||||
onJsonPathMiss({
|
||||
const evaluated = safeEvaluate(
|
||||
rootConfig.sourceField,
|
||||
rootConfig.jsonPath,
|
||||
null,
|
||||
);
|
||||
if (!evaluated.success) return withValue(undefined);
|
||||
if (evaluated.value === undefined) {
|
||||
misses.push({
|
||||
sourceField: rootConfig.sourceField,
|
||||
jsonPath: rootConfig.jsonPath,
|
||||
mappingKey: null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return withValue(evaluated.value);
|
||||
}
|
||||
|
||||
if (config.custom.type === "keyValueMap") {
|
||||
// Key-value map mode: build object from entries
|
||||
// Supports dot notation for nested objects (e.g., "context.user_id")
|
||||
const keyValueMapConfig = config.custom.keyValueMapConfig;
|
||||
if (!keyValueMapConfig || keyValueMapConfig.entries.length === 0) {
|
||||
return observation[defaultSourceField];
|
||||
return withValue(observation[defaultSourceField]);
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const entry of keyValueMapConfig.entries) {
|
||||
// Skip entries with empty values
|
||||
if (!entry.value && entry.value !== "") {
|
||||
continue;
|
||||
}
|
||||
if (!entry.value && entry.value !== "") continue;
|
||||
|
||||
let resolvedValue: unknown;
|
||||
if (isJsonPath(entry.value)) {
|
||||
// It's a JSON path - evaluate it
|
||||
const sourceData = observation[entry.sourceField];
|
||||
resolvedValue = evaluateJsonPath(sourceData, entry.value);
|
||||
if (resolvedValue === undefined && onJsonPathMiss) {
|
||||
onJsonPathMiss({
|
||||
sourceField: entry.sourceField,
|
||||
jsonPath: entry.value,
|
||||
mappingKey: entry.key,
|
||||
});
|
||||
const evaluated = safeEvaluate(
|
||||
entry.sourceField,
|
||||
entry.value,
|
||||
entry.key,
|
||||
);
|
||||
if (!evaluated.success) {
|
||||
resolvedValue = undefined;
|
||||
} else {
|
||||
resolvedValue = evaluated.value;
|
||||
if (resolvedValue === undefined) {
|
||||
misses.push({
|
||||
sourceField: entry.sourceField,
|
||||
jsonPath: entry.value,
|
||||
mappingKey: entry.key,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a literal string (including empty string)
|
||||
resolvedValue = entry.value;
|
||||
}
|
||||
|
||||
// Use dot notation path setter for nested objects
|
||||
if (entry.key.includes(".")) {
|
||||
setNestedValue(result, entry.key, resolvedValue);
|
||||
} else {
|
||||
result[entry.key] = resolvedValue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
return withValue(result);
|
||||
}
|
||||
|
||||
return observation[defaultSourceField];
|
||||
return withValue(observation[defaultSourceField]);
|
||||
}
|
||||
|
||||
default:
|
||||
return observation[defaultSourceField];
|
||||
return withValue(observation[defaultSourceField]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,47 +251,45 @@ export function applyFullMapping(props: {
|
||||
const results: Record<string, unknown> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const onJsonPathMiss = (info: {
|
||||
sourceField: SourceField;
|
||||
jsonPath: string;
|
||||
mappingKey: string | null;
|
||||
}) => {
|
||||
errors.push({
|
||||
type: "json_path_miss",
|
||||
targetField: field.key,
|
||||
sourceField: info.sourceField,
|
||||
jsonPath: info.jsonPath,
|
||||
mappingKey: info.mappingKey,
|
||||
message: `JSON path "${info.jsonPath}" did not match any data in "${info.sourceField}"${info.mappingKey ? ` (key: "${info.mappingKey}")` : ""}`,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
results[field.key] = applyFieldMappingConfig({
|
||||
const result = applyFieldMappingConfig({
|
||||
observation,
|
||||
config: field.config,
|
||||
defaultSourceField: field.defaultSourceField,
|
||||
onJsonPathMiss,
|
||||
});
|
||||
} catch (error) {
|
||||
// Capture rare JSONPath evaluation errors (e.g. malformed filter expressions)
|
||||
const sourceField =
|
||||
field.config.mode === "custom" && field.config.custom?.type === "root"
|
||||
? (field.config.custom.rootConfig?.sourceField ??
|
||||
field.defaultSourceField)
|
||||
: field.defaultSourceField;
|
||||
const jsonPath =
|
||||
field.config.mode === "custom" && field.config.custom?.type === "root"
|
||||
? (field.config.custom.rootConfig?.jsonPath ?? "")
|
||||
: "";
|
||||
results[field.key] = result.value;
|
||||
|
||||
for (const miss of result.misses) {
|
||||
errors.push({
|
||||
type: "json_path_miss",
|
||||
targetField: field.key,
|
||||
sourceField: miss.sourceField,
|
||||
jsonPath: miss.jsonPath,
|
||||
mappingKey: miss.mappingKey,
|
||||
message: `JSONPath "${miss.jsonPath}" did not match any data in "${miss.sourceField}"${miss.mappingKey ? ` (key: "${miss.mappingKey}")` : ""}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const err of result.errors) {
|
||||
errors.push({
|
||||
type: "json_path_error",
|
||||
targetField: field.key,
|
||||
sourceField: err.sourceField,
|
||||
jsonPath: err.jsonPath,
|
||||
mappingKey: err.mappingKey,
|
||||
message: `JSONPath evaluation error for "${field.key}"${err.mappingKey ? ` (key: "${err.mappingKey}")` : ""}: ${err.message}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Isolate per-field faults so a throw from one mapping doesn't abort
|
||||
// the remaining fields on the same observation.
|
||||
errors.push({
|
||||
type: "json_path_error",
|
||||
targetField: field.key,
|
||||
sourceField,
|
||||
jsonPath,
|
||||
sourceField: field.defaultSourceField,
|
||||
jsonPath: "",
|
||||
mappingKey: null,
|
||||
message: `JSON path evaluation error for "${field.key}": ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
message: `JSONPath evaluation error for "${field.key}": ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
});
|
||||
results[field.key] = undefined;
|
||||
}
|
||||
@@ -268,7 +304,7 @@ export function applyFullMapping(props: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate autocomplete suggestions for JSON paths based on the data structure
|
||||
* Generate autocomplete suggestions for JSONPaths based on the data structure
|
||||
*/
|
||||
export function generateJsonPathSuggestions(
|
||||
data: unknown,
|
||||
|
||||
@@ -25,6 +25,7 @@ export enum ActionId {
|
||||
ObservationAddToAnnotationQueue = "observation-add-to-annotation-queue",
|
||||
ObservationAddToDataset = "observation-add-to-dataset",
|
||||
ObservationBatchEvaluation = "observation-run-batched-evaluation",
|
||||
ExperimentCompare = "experiment-compare",
|
||||
}
|
||||
|
||||
const ActionIdSchema = z.nativeEnum(ActionId);
|
||||
|
||||
@@ -12,6 +12,35 @@ export type EvalTargetObject =
|
||||
|
||||
export const EvalTargetObjectSchema = z.enum(Object.values(EvalTargetObject));
|
||||
|
||||
// Batch action source tables that support evaluation
|
||||
export const BatchEvalSourceTable = {
|
||||
EVENTS: "events",
|
||||
EXPERIMENT_ITEMS: "experiment-items",
|
||||
EXPERIMENTS: "experiments",
|
||||
} as const;
|
||||
|
||||
export type BatchEvalSourceTable =
|
||||
(typeof BatchEvalSourceTable)[keyof typeof BatchEvalSourceTable];
|
||||
|
||||
export const BatchEvalSourceTableSchema = z.enum([
|
||||
BatchEvalSourceTable.EVENTS,
|
||||
BatchEvalSourceTable.EXPERIMENT_ITEMS,
|
||||
BatchEvalSourceTable.EXPERIMENTS,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Maps a batch evaluation source table to its corresponding eval target object.
|
||||
* - "events" → EvalTargetObject.EVENT (observation-scoped evaluators)
|
||||
* - "experiment-items" / "experiments" → EvalTargetObject.EXPERIMENT (experiment-scoped evaluators)
|
||||
*/
|
||||
export function getEvalTargetObjectFromSourceTable(
|
||||
sourceTable: BatchEvalSourceTable,
|
||||
): EvalTargetObject {
|
||||
return sourceTable === BatchEvalSourceTable.EVENTS
|
||||
? EvalTargetObject.EVENT
|
||||
: EvalTargetObject.EXPERIMENT;
|
||||
}
|
||||
|
||||
export const langfuseObjects = [
|
||||
"trace",
|
||||
"span",
|
||||
|
||||
@@ -53,14 +53,24 @@ function parseMultiEncodedJson(value: unknown): unknown {
|
||||
}
|
||||
|
||||
function parseJsonDefault(selectedColumn: unknown, jsonSelector: string) {
|
||||
// selectedColumn should already be preprocessed by preprocessObjectWithJsonFields
|
||||
// so we can directly use it with JSONPath
|
||||
// JSONPath can only query objects/arrays — return primitives as-is
|
||||
if (typeof selectedColumn !== "object" || selectedColumn === null) {
|
||||
return selectedColumn;
|
||||
}
|
||||
|
||||
const result = JSONPath({
|
||||
path: jsonSelector,
|
||||
json: selectedColumn as any, // JSONPath accepts unknown but types are strict
|
||||
eval: false,
|
||||
});
|
||||
|
||||
return Array.isArray(result) && result.length > 0 ? result[0] : undefined;
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// For single-match queries (e.g. $.name), return the unwrapped value.
|
||||
// For multi-match queries (e.g. $[1:], $[*].name), return the full array.
|
||||
return result.length === 1 ? result[0] : result;
|
||||
}
|
||||
|
||||
export function extractValueFromObject(
|
||||
@@ -69,12 +79,7 @@ export function extractValueFromObject(
|
||||
jsonSelector?: string,
|
||||
parseJson?: (selectedColumn: unknown, jsonSelector: string) => unknown,
|
||||
): { value: string; error: Error | null } {
|
||||
let selectedColumn = obj[selectedColumnId];
|
||||
|
||||
// Simple preprocessing: attempt to parse to valid JSON object
|
||||
if (typeof selectedColumn === "string") {
|
||||
selectedColumn = parseMultiEncodedJson(selectedColumn);
|
||||
}
|
||||
const selectedColumn = obj[selectedColumnId];
|
||||
|
||||
const jsonParser = parseJson || parseJsonDefault;
|
||||
|
||||
@@ -82,14 +87,21 @@ export function extractValueFromObject(
|
||||
let error: Error | null = null;
|
||||
|
||||
if (jsonSelector && selectedColumn) {
|
||||
// Only parse multi-encoded JSON when a selector is present — avoids
|
||||
// mutating formatting (e.g. whitespace) for the no-selector passthrough.
|
||||
const parsed =
|
||||
typeof selectedColumn === "string"
|
||||
? parseMultiEncodedJson(selectedColumn)
|
||||
: selectedColumn;
|
||||
|
||||
try {
|
||||
jsonSelectedColumn = jsonParser(selectedColumn, jsonSelector);
|
||||
jsonSelectedColumn = jsonParser(parsed, jsonSelector);
|
||||
} catch (err) {
|
||||
error =
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error("There was an unknown error parsing the JSON");
|
||||
jsonSelectedColumn = selectedColumn; // Fallback to original value
|
||||
jsonSelectedColumn = selectedColumn; // Fallback to raw original value
|
||||
}
|
||||
} else {
|
||||
jsonSelectedColumn = selectedColumn;
|
||||
|
||||
@@ -11,12 +11,25 @@ export const VERTEXAI_USE_DEFAULT_CREDENTIALS =
|
||||
export const BedrockConfigSchema = z.object({ region: z.string() });
|
||||
export type BedrockConfig = z.infer<typeof BedrockConfigSchema>;
|
||||
|
||||
export const BedrockCredentialSchema = z
|
||||
export const BedrockAccessKeysSchema = z
|
||||
.object({
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
accessKeyId: z.string().min(1),
|
||||
secretAccessKey: z.string().min(1),
|
||||
})
|
||||
.optional();
|
||||
.strict();
|
||||
export type BedrockAccessKeys = z.infer<typeof BedrockAccessKeysSchema>;
|
||||
|
||||
export const BedrockApiKeySchema = z
|
||||
.object({
|
||||
apiKey: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
export type BedrockApiKey = z.infer<typeof BedrockApiKeySchema>;
|
||||
|
||||
export const BedrockCredentialSchema = z.union([
|
||||
BedrockAccessKeysSchema,
|
||||
BedrockApiKeySchema,
|
||||
]);
|
||||
export type BedrockCredential = z.infer<typeof BedrockCredentialSchema>;
|
||||
|
||||
export const VertexAIConfigSchema = z
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./localCache";
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { logger } from "../logger";
|
||||
import { recordGauge, recordIncrement } from "../instrumentation";
|
||||
|
||||
export type LocalCacheLoadResult<V> = {
|
||||
value: V | undefined;
|
||||
ttlMs?: number;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type LocalCacheConfig = {
|
||||
namespace: string;
|
||||
enabled: boolean;
|
||||
ttlMs: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
export class LocalCache<V extends {}> {
|
||||
private readonly config: LocalCacheConfig;
|
||||
private readonly cache: LRUCache<string, V>;
|
||||
|
||||
constructor(config: LocalCacheConfig) {
|
||||
this.config = config;
|
||||
const dispose: LRUCache.Disposer<string, V> = (_value, _key, reason) => {
|
||||
if (reason === "evict") {
|
||||
this.record("evict");
|
||||
this.recordSizeMetrics();
|
||||
}
|
||||
};
|
||||
|
||||
const baseOptions = {
|
||||
ttlAutopurge: false as const,
|
||||
allowStale: false as const,
|
||||
updateAgeOnGet: false as const,
|
||||
updateAgeOnHas: false as const,
|
||||
dispose,
|
||||
};
|
||||
|
||||
this.cache = new LRUCache<string, V>({
|
||||
...baseOptions,
|
||||
ttl: config.ttlMs,
|
||||
max: config.max,
|
||||
});
|
||||
this.logInfo("Initialized local cache", {
|
||||
enabled: config.enabled,
|
||||
ttlMs: config.ttlMs,
|
||||
max: config.max,
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
if (!this.config.enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = this.cache.get(key);
|
||||
this.record(value === undefined ? "miss" : "hit");
|
||||
this.logDebug(
|
||||
value === undefined ? "Local cache miss" : "Local cache hit",
|
||||
{
|
||||
size: this.cache.size,
|
||||
keyLength: key.length,
|
||||
},
|
||||
);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: string, value: V): void {
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ttlMs = this.config.ttlMs;
|
||||
|
||||
try {
|
||||
this.cache.set(key, value, { ttl: ttlMs });
|
||||
this.record("set");
|
||||
this.recordSizeMetrics();
|
||||
this.logDebug("Stored local cache entry", {
|
||||
ttlMs,
|
||||
size: this.cache.size,
|
||||
keyLength: key.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to set local cache entry for namespace ${this.config.namespace}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
this.record("clear");
|
||||
this.recordSizeMetrics();
|
||||
this.logDebug("Cleared local cache");
|
||||
}
|
||||
|
||||
async getOrLoad(
|
||||
key: string,
|
||||
loader: () => Promise<LocalCacheLoadResult<V>>,
|
||||
): Promise<LocalCacheLoadResult<V>> {
|
||||
const cached = this.get(key);
|
||||
if (cached !== undefined) {
|
||||
return { value: cached, source: "local" };
|
||||
}
|
||||
|
||||
if (!this.config.enabled) {
|
||||
this.logDebug("Bypassing disabled local cache", {
|
||||
keyLength: key.length,
|
||||
});
|
||||
return loader();
|
||||
}
|
||||
|
||||
const result = await loader();
|
||||
this.logDebug("Completed local cache load", {
|
||||
source: result.source ?? "unknown",
|
||||
cacheable: result.value !== undefined,
|
||||
ttlMs: result.ttlMs ?? null,
|
||||
keyLength: key.length,
|
||||
});
|
||||
|
||||
if (result.value !== undefined) {
|
||||
this.set(key, result.value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private record(metric: string): void {
|
||||
recordIncrement(`langfuse.local_cache.${metric}`, 1, {
|
||||
namespace: this.config.namespace,
|
||||
});
|
||||
}
|
||||
|
||||
private recordSizeMetrics(): void {
|
||||
recordGauge("langfuse.local_cache.size_entries", this.cache.size, {
|
||||
namespace: this.config.namespace,
|
||||
});
|
||||
}
|
||||
|
||||
private logDebug(message: string, metadata?: Record<string, unknown>): void {
|
||||
if (!logger.isLevelEnabled("debug")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedMetadata =
|
||||
metadata === undefined ? "" : ` ${safeSerialize(metadata)}`;
|
||||
|
||||
logger.debug(
|
||||
`[LocalCache:${this.config.namespace}] ${message}${formattedMetadata}`,
|
||||
);
|
||||
}
|
||||
|
||||
private logInfo(message: string, metadata?: Record<string, unknown>): void {
|
||||
const formattedMetadata =
|
||||
metadata === undefined ? "" : ` ${safeSerialize(metadata)}`;
|
||||
|
||||
logger.info(
|
||||
`[LocalCache:${this.config.namespace}] ${message}${formattedMetadata}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const safeSerialize = (value: unknown): string => {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./services/StorageService";
|
||||
export * from "./cache";
|
||||
export * from "./services/BufferedStreamUploader";
|
||||
export * from "./services/S3ChunkedUploadStrategy";
|
||||
export * from "./services/email/organizationInvitation/sendMembershipInvitationEmail";
|
||||
@@ -28,6 +29,7 @@ export * from "./llm/fetchLLMCompletion";
|
||||
export * from "./llm/errors";
|
||||
export * from "./llm/utils";
|
||||
export * from "./llm/types";
|
||||
export * from "./llm/internalTraceEvents";
|
||||
export * from "./llm/compileChatMessages";
|
||||
export * from "./llm/testModelCall";
|
||||
export * from "./llm/baseUrlValidation";
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
safeMultiDel,
|
||||
scanKeys,
|
||||
} from "../";
|
||||
import { LocalCache } from "../cache";
|
||||
import { env } from "../../env";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { prisma } from "../../db";
|
||||
@@ -24,6 +25,22 @@ export type ModelWithPrices = {
|
||||
};
|
||||
|
||||
const MODEL_MATCH_CACHE_LOCKED_KEY = "LOCK:model-match-clear";
|
||||
const DEFAULT_LOCAL_CACHE_MODEL_MATCH_TTL_MS = 10_000;
|
||||
const DEFAULT_LOCAL_CACHE_MODEL_MATCH_MAX = 20_000;
|
||||
// This L1 cache is intentionally TTL-only. Cross-container consistency continues
|
||||
// to come from Redis invalidation plus the short local TTL.
|
||||
const modelMatchLocalCache = new LocalCache<ModelWithPrices>({
|
||||
namespace: "model_match",
|
||||
enabled: env.LANGFUSE_LOCAL_CACHE_MODEL_MATCH_ENABLED === "true",
|
||||
ttlMs: getPositiveNumberOrDefault(
|
||||
env.LANGFUSE_LOCAL_CACHE_MODEL_MATCH_TTL_MS,
|
||||
DEFAULT_LOCAL_CACHE_MODEL_MATCH_TTL_MS,
|
||||
),
|
||||
max: getPositiveNumberOrDefault(
|
||||
env.LANGFUSE_LOCAL_CACHE_MODEL_MATCH_MAX,
|
||||
DEFAULT_LOCAL_CACHE_MODEL_MATCH_MAX,
|
||||
),
|
||||
});
|
||||
|
||||
export async function findModel(p: ModelMatchProps): Promise<ModelWithPrices> {
|
||||
return instrumentAsync(
|
||||
@@ -33,66 +50,132 @@ export async function findModel(p: ModelMatchProps): Promise<ModelWithPrices> {
|
||||
},
|
||||
async (span) => {
|
||||
if (logger.isLevelEnabled("debug")) {
|
||||
logger.debug(`Finding model for ${JSON.stringify(p)}`);
|
||||
}
|
||||
const cachedResult = await getModelWithPricesFromRedis(p);
|
||||
if (cachedResult) {
|
||||
span.setAttribute("model_match_source", "redis");
|
||||
|
||||
if (cachedResult.model === null) {
|
||||
return { model: null, pricingTiers: [] };
|
||||
} else {
|
||||
logger.debug(
|
||||
`Found model name ${cachedResult.model?.modelName} (id: ${cachedResult.model?.id}) for project ${p.projectId} and model ${p.model}`,
|
||||
);
|
||||
span.setAttribute("matched_model_id", cachedResult.model.id);
|
||||
}
|
||||
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
// try to find model in Postgres
|
||||
const postgresModel = await findModelInPostgres(p);
|
||||
|
||||
if (postgresModel && env.LANGFUSE_CACHE_MODEL_MATCH_ENABLED === "true") {
|
||||
const pricingTiers = await findPricingTiersForModel(postgresModel.id);
|
||||
await addModelWithPricingTiersToRedis(p, postgresModel, pricingTiers);
|
||||
|
||||
span.setAttribute("matched_model_id", postgresModel.id);
|
||||
span.setAttribute("model_match_source", "postgres");
|
||||
span.setAttribute("model_cache_set", "true");
|
||||
|
||||
logger.debug(
|
||||
`Found model name ${postgresModel?.modelName} (id: ${postgresModel?.id}) for project ${p.projectId} and model ${p.model}`,
|
||||
formatModelMatchDebugMessage("Resolving model match", {
|
||||
projectId: p.projectId,
|
||||
model: p.model,
|
||||
localCacheEnabled:
|
||||
env.LANGFUSE_LOCAL_CACHE_MODEL_MATCH_ENABLED === "true",
|
||||
redisCacheEnabled:
|
||||
env.LANGFUSE_CACHE_MODEL_MATCH_ENABLED === "true",
|
||||
}),
|
||||
);
|
||||
return { model: postgresModel, pricingTiers };
|
||||
} else if (postgresModel) {
|
||||
const pricingTiers = await findPricingTiersForModel(postgresModel.id);
|
||||
span.setAttribute("matched_model_id", postgresModel.id);
|
||||
span.setAttribute("model_match_source", "postgres");
|
||||
span.setAttribute("model_cache_set", "false");
|
||||
}
|
||||
const localCacheKey = getRedisModelKey(p);
|
||||
const { source, value } = await modelMatchLocalCache.getOrLoad(
|
||||
localCacheKey,
|
||||
async () => {
|
||||
const cachedResult = await getModelWithPricesFromRedis(p);
|
||||
if (cachedResult) {
|
||||
return {
|
||||
value: cachedResult,
|
||||
source: "redis",
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Found model name ${postgresModel?.modelName} (id: ${postgresModel?.id}) for project ${p.projectId} and model ${p.model}`,
|
||||
);
|
||||
return { model: postgresModel, pricingTiers };
|
||||
} else {
|
||||
span.setAttribute("model_match_source", "none");
|
||||
const postgresModel = await findModelInPostgres(p);
|
||||
if (postgresModel) {
|
||||
const pricingTiers = await findPricingTiersForModel(
|
||||
postgresModel.id,
|
||||
);
|
||||
|
||||
if (env.LANGFUSE_CACHE_MODEL_MATCH_ENABLED === "true") {
|
||||
await addModelNotFoundTokenToRedis(p);
|
||||
if (env.LANGFUSE_CACHE_MODEL_MATCH_ENABLED === "true") {
|
||||
await addModelWithPricingTiersToRedis(
|
||||
p,
|
||||
postgresModel,
|
||||
pricingTiers,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
value: { model: postgresModel, pricingTiers },
|
||||
source: "postgres",
|
||||
};
|
||||
}
|
||||
|
||||
if (env.LANGFUSE_CACHE_MODEL_MATCH_ENABLED === "true") {
|
||||
await addModelNotFoundTokenToRedis(p);
|
||||
}
|
||||
|
||||
return {
|
||||
value: { model: null, pricingTiers: [] },
|
||||
source: "none",
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
if (!value || value.model === null) {
|
||||
span.setAttribute("model_match_source", source ?? "none");
|
||||
if (
|
||||
source === "none" &&
|
||||
env.LANGFUSE_CACHE_MODEL_MATCH_ENABLED === "true"
|
||||
) {
|
||||
span.setAttribute("model_cache_set", "true");
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Model not found for project ${p.projectId} and model ${p.model}`,
|
||||
);
|
||||
if (logger.isLevelEnabled("debug")) {
|
||||
logger.debug(
|
||||
formatModelMatchDebugMessage(
|
||||
"Model match resolved without a model",
|
||||
{
|
||||
projectId: p.projectId,
|
||||
model: p.model,
|
||||
source: source ?? "none",
|
||||
pricingTierCount: 0,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { model: null, pricingTiers: [] };
|
||||
}
|
||||
|
||||
span.setAttribute("model_match_source", source ?? "unknown");
|
||||
span.setAttribute("matched_model_id", value.model.id);
|
||||
if (source === "postgres") {
|
||||
span.setAttribute(
|
||||
"model_cache_set",
|
||||
String(env.LANGFUSE_CACHE_MODEL_MATCH_ENABLED === "true"),
|
||||
);
|
||||
}
|
||||
|
||||
if (logger.isLevelEnabled("debug")) {
|
||||
logger.debug(
|
||||
formatModelMatchDebugMessage("Model match resolved", {
|
||||
projectId: p.projectId,
|
||||
model: p.model,
|
||||
source: source ?? "unknown",
|
||||
matchedModelId: value.model.id,
|
||||
matchedModelName: value.model.modelName,
|
||||
pricingTierCount: value.pricingTiers.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const formatModelMatchDebugMessage = (
|
||||
message: string,
|
||||
metadata: Record<string, unknown>,
|
||||
): string => {
|
||||
try {
|
||||
return `${message} ${JSON.stringify(metadata)}`;
|
||||
} catch {
|
||||
return `${message} [unserializable]`;
|
||||
}
|
||||
};
|
||||
|
||||
function getPositiveNumberOrDefault(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export const clearModelMatchLocalCache = (): void => {
|
||||
modelMatchLocalCache.clear();
|
||||
};
|
||||
|
||||
const getModelWithPricesFromRedis = async (
|
||||
p: ModelMatchProps,
|
||||
): Promise<ModelWithPrices | null> => {
|
||||
|
||||
@@ -9,6 +9,31 @@ import { logger } from "../logger";
|
||||
|
||||
// type CallbackFn<T> = () => T;
|
||||
|
||||
/**
|
||||
* IORedis request hook that records the full Redis command as a span attribute.
|
||||
* Redacts credentials from AUTH/HELLO and values from API key cache operations.
|
||||
*/
|
||||
export function ioredisRequestHook(
|
||||
span: opentelemetry.Span,
|
||||
{ cmdName, cmdArgs }: { cmdName: string; cmdArgs: unknown[] },
|
||||
): void {
|
||||
if (!Array.isArray(cmdArgs) || cmdArgs.length === 0) return;
|
||||
const cmd = cmdName.toUpperCase();
|
||||
// AUTH and HELLO carry raw credentials — redact all args
|
||||
if (cmd === "AUTH" || cmd === "HELLO") {
|
||||
span.setAttribute("redis.full_command", `${cmdName} [REDACTED]`);
|
||||
return;
|
||||
}
|
||||
const args = [...cmdArgs].map(String);
|
||||
// Redact API key cache values: SET [prefix:]api-key:{hash} <json>
|
||||
if (args[0]?.includes("api-key:")) {
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
args[i] = "[REDACTED]";
|
||||
}
|
||||
}
|
||||
span.setAttribute("redis.full_command", `${cmdName} ${args.join(" ")}`);
|
||||
}
|
||||
|
||||
export type TCarrier = {
|
||||
traceparent?: string;
|
||||
tracestate?: string;
|
||||
@@ -263,6 +288,25 @@ const flushMetricsToCloudWatch = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Metrics ending with these suffixes have their tags flattened into the
|
||||
// CloudWatch metric name (excluding "unit"). Other metrics are unaffected.
|
||||
const CW_TAG_FLATTENED_SUFFIXES = [".depth", ".rate"];
|
||||
|
||||
function buildCloudWatchKey(
|
||||
stat: string,
|
||||
tags?: { [tag: string]: string | number },
|
||||
): string {
|
||||
if (!tags || !CW_TAG_FLATTENED_SUFFIXES.some((s) => stat.endsWith(s))) {
|
||||
return stat;
|
||||
}
|
||||
const suffix = Object.entries(tags)
|
||||
.filter(([k]) => k !== "unit")
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}_${v}`)
|
||||
.join(".");
|
||||
return suffix ? `${stat}.${suffix}` : stat;
|
||||
}
|
||||
|
||||
export const recordGauge = (
|
||||
stat: string,
|
||||
value?: number | undefined,
|
||||
@@ -273,7 +317,7 @@ export const recordGauge = (
|
||||
| undefined,
|
||||
) => {
|
||||
if (env.ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING === "true") {
|
||||
sendCloudWatchMetric(stat, value ?? 0, true);
|
||||
sendCloudWatchMetric(buildCloudWatchKey(stat, tags), value ?? 0, true);
|
||||
}
|
||||
dd.dogstatsd.gauge(stat, value, tags);
|
||||
};
|
||||
@@ -284,7 +328,7 @@ export const recordIncrement = (
|
||||
tags?: { [tag: string]: string | number } | undefined,
|
||||
) => {
|
||||
if (env.ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING === "true") {
|
||||
sendCloudWatchMetric(stat, value ?? 1, false);
|
||||
sendCloudWatchMetric(buildCloudWatchKey(stat, tags), value ?? 1, false);
|
||||
}
|
||||
dd.dogstatsd.increment(stat, value, tags);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import { IterableReadableStream } from "@langchain/core/utils/stream";
|
||||
import { ChatOpenAI, AzureChatOpenAI } from "@langchain/openai";
|
||||
import { env } from "../../env";
|
||||
import GCPServiceAccountKeySchema, {
|
||||
BedrockAccessKeysSchema,
|
||||
BedrockConfigSchema,
|
||||
BedrockCredentialSchema,
|
||||
VertexAIConfigSchema,
|
||||
@@ -53,6 +54,14 @@ import { LLMCompletionError } from "./errors";
|
||||
|
||||
export type CompletionWithReasoning = { text: string; reasoning?: string };
|
||||
|
||||
const NON_RETRYABLE_LLM_ERROR_PATTERNS = [
|
||||
"Request timed out",
|
||||
"is not valid JSON",
|
||||
"Unterminated string in JSON at position",
|
||||
"TypeError",
|
||||
"reached the end of its life",
|
||||
] as const;
|
||||
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
// Maps adapters to the content block types that represent "thinking".
|
||||
@@ -90,6 +99,52 @@ const googleProviderOptionsSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
// For using Bedrock API key in Bearer token format
|
||||
const createBedrockBearerAuth = (token: string) => ({
|
||||
clientOptions: {
|
||||
token: { token },
|
||||
authSchemePreference: ["httpBearerAuth"],
|
||||
},
|
||||
});
|
||||
|
||||
export function resolveBedrockAuth(params: {
|
||||
secretKey: string;
|
||||
allowDefaultCredentials: boolean;
|
||||
}): {
|
||||
credentials?: z.infer<typeof BedrockAccessKeysSchema>;
|
||||
clientOptions?: {
|
||||
token: { token: string };
|
||||
authSchemePreference: string[];
|
||||
};
|
||||
} {
|
||||
const { secretKey, allowDefaultCredentials } = params;
|
||||
|
||||
if (
|
||||
secretKey === BEDROCK_USE_DEFAULT_CREDENTIALS &&
|
||||
allowDefaultCredentials
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedCredential = BedrockCredentialSchema.parse(
|
||||
JSON.parse(secretKey),
|
||||
);
|
||||
|
||||
if ("apiKey" in parsedCredential) {
|
||||
return createBedrockBearerAuth(parsedCredential.apiKey);
|
||||
}
|
||||
|
||||
return {
|
||||
credentials: parsedCredential,
|
||||
};
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Invalid Bedrock credentials. Expected AWS access key JSON or a Bedrock API key.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type ProcessTracedEvents = () => Promise<void>;
|
||||
|
||||
type LLMCompletionParams = {
|
||||
@@ -363,16 +418,16 @@ export async function fetchLLMCompletion(
|
||||
// Handle both explicit credentials and default provider chain
|
||||
// Only allow default provider chain in self-hosted or internal AI features
|
||||
const isSelfHosted = !isLangfuseCloud;
|
||||
const credentials =
|
||||
apiKey === BEDROCK_USE_DEFAULT_CREDENTIALS &&
|
||||
(isSelfHosted || shouldUseLangfuseAPIKey)
|
||||
? undefined // undefined = use AWS SDK default credential provider chain
|
||||
: BedrockCredentialSchema.parse(JSON.parse(apiKey));
|
||||
const { credentials, clientOptions } = resolveBedrockAuth({
|
||||
secretKey: apiKey,
|
||||
allowDefaultCredentials: isSelfHosted || shouldUseLangfuseAPIKey,
|
||||
});
|
||||
|
||||
chatModel = new ChatBedrockConverse({
|
||||
model: modelParams.model,
|
||||
region,
|
||||
credentials,
|
||||
clientOptions,
|
||||
temperature: modelParams.temperature,
|
||||
maxTokens: modelParams.max_tokens,
|
||||
topP: modelParams.top_p,
|
||||
@@ -577,20 +632,17 @@ export async function fetchLLMCompletion(
|
||||
return completion;
|
||||
} catch (e) {
|
||||
const responseStatusCode =
|
||||
(e as any)?.response?.status ?? (e as any)?.status ?? 500;
|
||||
(e as any)?.response?.status ??
|
||||
(e as any)?.status ??
|
||||
// Bedrock errors have status code in $metadata.httpStatusCode
|
||||
(e as any)?.$metadata?.httpStatusCode ??
|
||||
500;
|
||||
const rawMessage = e instanceof Error ? e.message : String(e);
|
||||
const message = extractCleanErrorMessage(rawMessage);
|
||||
|
||||
// Check for non-retryable error patterns in message
|
||||
const nonRetryablePatterns = [
|
||||
"Request timed out",
|
||||
"is not valid JSON",
|
||||
"Unterminated string in JSON at position",
|
||||
"TypeError",
|
||||
];
|
||||
|
||||
const hasNonRetryablePattern = nonRetryablePatterns.some((pattern) =>
|
||||
message.includes(pattern),
|
||||
const hasNonRetryablePattern = NON_RETRYABLE_LLM_ERROR_PATTERNS.some(
|
||||
(pattern) => message.includes(pattern),
|
||||
);
|
||||
|
||||
// Determine retryability:
|
||||
|
||||
@@ -1,88 +1,22 @@
|
||||
import CallbackHandler from "langfuse-langchain";
|
||||
import { GenerationDetails, TraceSinkParams } from "./types";
|
||||
import { ProcessedTraceEvent, TraceSinkParams } from "./types";
|
||||
import { buildInternalTraceEventInputs } from "./internalTraceEvents";
|
||||
import { processEventBatch } from "../ingestion/processEventBatch";
|
||||
import { logger } from "../logger";
|
||||
import { traceException } from "../instrumentation";
|
||||
|
||||
type TracedEvent = {
|
||||
type: string;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
export function prepareInternalTraceEvents(params: {
|
||||
events: Array<{
|
||||
type: string;
|
||||
timestamp: string;
|
||||
body: Record<string, unknown>;
|
||||
}>;
|
||||
environment: string;
|
||||
prompt?: TraceSinkParams["prompt"];
|
||||
}): ProcessedTraceEvent[] {
|
||||
const { events, environment, prompt } = params;
|
||||
|
||||
/**
|
||||
* Extracts and merges generation details from a list of processed events.
|
||||
* Handles multiple generation-create and generation-update events with the same id.
|
||||
*
|
||||
* Events are merged following the "last non-null value wins" pattern:
|
||||
* - generation-create events contain: id, name, input, metadata
|
||||
* - generation-update events contain: output, usage, usageDetails
|
||||
*
|
||||
* @returns GenerationDetails or null if no generation events found
|
||||
*/
|
||||
export function extractGenerationDetails(
|
||||
processedEvents: Array<{ type: string; body: Record<string, unknown> }>,
|
||||
): GenerationDetails | null {
|
||||
// 1. Filter to only generation events
|
||||
const generationEvents = processedEvents.filter(
|
||||
(event) =>
|
||||
event.type === "generation-create" || event.type === "generation-update",
|
||||
);
|
||||
|
||||
if (generationEvents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Get the generation id from first event
|
||||
const generationId = generationEvents[0].body.id as string;
|
||||
if (!generationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Filter to events for this generation id only
|
||||
const eventsForGeneration = generationEvents.filter(
|
||||
(event) => event.body.id === generationId,
|
||||
);
|
||||
|
||||
// 4. Merge event bodies (last non-null/non-undefined value wins)
|
||||
// Similar to IngestionService pattern but simplified for our use case
|
||||
const mergedBody = eventsForGeneration.reduce(
|
||||
(acc: Record<string, unknown>, event) => {
|
||||
for (const [key, value] of Object.entries(event.body)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
// Special handling for metadata: deep merge
|
||||
if (
|
||||
key === "metadata" &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
acc[key] = {
|
||||
...((acc[key] as Record<string, unknown>) || {}),
|
||||
...(value as Record<string, unknown>),
|
||||
};
|
||||
} else {
|
||||
acc[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ id: generationId },
|
||||
);
|
||||
|
||||
return {
|
||||
observationId: generationId,
|
||||
name: (mergedBody.name as string) || "generation",
|
||||
input: mergedBody.input,
|
||||
output: mergedBody.output,
|
||||
metadata: (mergedBody.metadata as Record<string, unknown>) || {},
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareTracedEventsForIngestion(
|
||||
events: TracedEvent[],
|
||||
{ environment, prompt }: Pick<TraceSinkParams, "environment" | "prompt">,
|
||||
): TracedEvent[] {
|
||||
const blockedSpanIds = new Set<string>();
|
||||
const blockedSpanIds = new Set();
|
||||
const blockedSpanNames = [
|
||||
"RunnableLambda",
|
||||
"StructuredOutputParser",
|
||||
@@ -91,29 +25,27 @@ export function prepareTracedEventsForIngestion(
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
const eventName = event.body.name;
|
||||
const eventName = "name" in event.body ? event.body.name : "";
|
||||
|
||||
if (typeof eventName !== "string" || eventName.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
blockedSpanNames.includes(eventName) &&
|
||||
typeof event.body.id === "string"
|
||||
) {
|
||||
if (blockedSpanNames.includes(eventName as string) && "id" in event.body) {
|
||||
blockedSpanIds.add(event.body.id);
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
.filter((event) => {
|
||||
if (typeof event.body.id === "string") {
|
||||
if ("id" in event.body) {
|
||||
return !blockedSpanIds.has(event.body.id);
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((event) => {
|
||||
// Inject environment into all events
|
||||
return {
|
||||
...event,
|
||||
body: {
|
||||
@@ -142,7 +74,8 @@ export function getInternalTracingHandler(traceSinkParams: TraceSinkParams): {
|
||||
handler: CallbackHandler;
|
||||
processTracedEvents: () => Promise<void>;
|
||||
} {
|
||||
const { prompt, targetProjectId, environment, userId } = traceSinkParams;
|
||||
const { prompt, targetProjectId, environment, userId, eventsWriter } =
|
||||
traceSinkParams;
|
||||
const handler = new CallbackHandler({
|
||||
_projectId: targetProjectId,
|
||||
_isLocalEventExportEnabled: true,
|
||||
@@ -155,41 +88,52 @@ export function getInternalTracingHandler(traceSinkParams: TraceSinkParams): {
|
||||
const events = await handler.langfuse._exportLocalEvents(
|
||||
traceSinkParams.targetProjectId,
|
||||
);
|
||||
const processedEvents = prepareInternalTraceEvents({
|
||||
events,
|
||||
environment,
|
||||
prompt,
|
||||
});
|
||||
|
||||
const processedEvents = prepareTracedEventsForIngestion(
|
||||
events as TracedEvent[],
|
||||
{
|
||||
environment,
|
||||
prompt,
|
||||
},
|
||||
);
|
||||
// Legacy write to traces/observations tables
|
||||
try {
|
||||
await processEventBatch(
|
||||
JSON.parse(JSON.stringify(processedEvents)), // stringify to emulate network event batch from network call
|
||||
{
|
||||
validKey: true as const,
|
||||
scope: {
|
||||
projectId: traceSinkParams.targetProjectId, // Important: this controls into what project traces are ingested.
|
||||
accessLevel: "project",
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
isLangfuseInternal: true,
|
||||
forwardToEventsTable: eventsWriter ? false : undefined, // Do not dual write when we already direct event write
|
||||
},
|
||||
);
|
||||
} catch (processingError) {
|
||||
traceException(processingError);
|
||||
logger.error("Failed to process traced events via legacy ingestion", {
|
||||
error: processingError,
|
||||
});
|
||||
}
|
||||
|
||||
await processEventBatch(
|
||||
JSON.parse(JSON.stringify(processedEvents)), // stringify to emulate network event batch from network call
|
||||
{
|
||||
validKey: true as const,
|
||||
scope: {
|
||||
projectId: traceSinkParams.targetProjectId, // Important: this controls into what project traces are ingested.
|
||||
accessLevel: "project",
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
isLangfuseInternal: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Extract generation details and invoke callback (if provided)
|
||||
if (traceSinkParams.onGenerationComplete) {
|
||||
// Direct write to events table
|
||||
if (eventsWriter) {
|
||||
try {
|
||||
const generationDetails = extractGenerationDetails(processedEvents);
|
||||
if (generationDetails) {
|
||||
traceSinkParams.onGenerationComplete(generationDetails);
|
||||
const { rootSpanId, eventInputs } = buildInternalTraceEventInputs({
|
||||
processedEvents,
|
||||
traceId: traceSinkParams.traceId,
|
||||
projectId: targetProjectId,
|
||||
experimentContext: eventsWriter.experimentContext,
|
||||
});
|
||||
|
||||
if (eventInputs.length > 0) {
|
||||
await eventsWriter.write({ rootSpanId, eventInputs });
|
||||
}
|
||||
} catch (extractionError) {
|
||||
// Don't fail the LLM call due to generation detail extraction errors
|
||||
traceException(extractionError);
|
||||
logger.error("Failed to extract generation details from events", {
|
||||
error: extractionError,
|
||||
} catch (writeError) {
|
||||
traceException(writeError);
|
||||
logger.error("Failed to direct-write internal traced events", {
|
||||
error: writeError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import {
|
||||
asBoolean,
|
||||
asNumberRecord,
|
||||
asRecord,
|
||||
asString,
|
||||
asStringArray,
|
||||
} from "../../utils/objects";
|
||||
import { stringifyValue } from "../../utils/stringChecks";
|
||||
import {
|
||||
convertCallsToArrays,
|
||||
convertDefinitionsToMap,
|
||||
extractToolsFromObservation,
|
||||
} from "../ingestion/extractToolsBackend";
|
||||
import { flattenJsonToPathArrays } from "../otel/utils";
|
||||
import type { ProcessedTraceEvent } from "./types";
|
||||
|
||||
export const INTERNAL_TRACE_EVENT_SOURCE = "ingestion-api-dual-write";
|
||||
export const INTERNAL_TRACE_EXPERIMENT_EVENT_SOURCE =
|
||||
"ingestion-api-dual-write-experiments";
|
||||
|
||||
export type InternalTraceExperimentContext = {
|
||||
id: string;
|
||||
name: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
description?: string | null;
|
||||
datasetId: string;
|
||||
itemId: string;
|
||||
itemVersion: string;
|
||||
itemExpectedOutput?: unknown;
|
||||
itemMetadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flexible input type for writing events to the events table.
|
||||
* This is intentionally loose to allow for iteration as the events
|
||||
* table schema evolves. Only required fields are enforced.
|
||||
*/
|
||||
export type InternalTraceEventInput = {
|
||||
projectId: string;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
startTimeISO: string;
|
||||
orgId?: string;
|
||||
parentSpanId?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
environment?: string;
|
||||
version?: string;
|
||||
release?: string;
|
||||
endTimeISO: string;
|
||||
completionStartTime?: string;
|
||||
traceName?: string;
|
||||
tags?: string[];
|
||||
bookmarked?: boolean;
|
||||
public?: boolean;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
level?: string;
|
||||
statusMessage?: string;
|
||||
promptId?: string;
|
||||
promptName?: string;
|
||||
promptVersion?: string;
|
||||
modelId?: string;
|
||||
modelName?: string;
|
||||
modelParameters?: string | Record<string, unknown>;
|
||||
providedUsageDetails?: Record<string, number>;
|
||||
usageDetails?: Record<string, number>;
|
||||
providedCostDetails?: Record<string, number>;
|
||||
costDetails?: Record<string, number>;
|
||||
toolDefinitions?: Record<string, string>;
|
||||
toolCalls?: string[];
|
||||
toolCallNames?: string[];
|
||||
input?: string;
|
||||
output?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
source: string;
|
||||
serviceName?: string;
|
||||
serviceVersion?: string;
|
||||
scopeName?: string;
|
||||
scopeVersion?: string;
|
||||
telemetrySdkLanguage?: string;
|
||||
telemetrySdkName?: string;
|
||||
telemetrySdkVersion?: string;
|
||||
blobStorageFilePath?: string;
|
||||
eventRaw?: string;
|
||||
eventBytes?: number;
|
||||
experimentId?: string;
|
||||
experimentName?: string;
|
||||
experimentMetadataNames?: string[];
|
||||
experimentMetadataValues?: Array<string | null | undefined>;
|
||||
experimentDescription?: string;
|
||||
experimentDatasetId?: string;
|
||||
experimentItemId?: string;
|
||||
experimentItemVersion?: string;
|
||||
experimentItemRootSpanId?: string;
|
||||
experimentItemExpectedOutput?: string;
|
||||
experimentItemMetadataNames?: string[];
|
||||
experimentItemMetadataValues?: Array<string | null | undefined>;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type InternalTraceSnapshot = {
|
||||
spanId: string;
|
||||
traceId: string;
|
||||
parentSpanId?: string;
|
||||
name?: string;
|
||||
type: "SPAN" | "GENERATION";
|
||||
environment?: string;
|
||||
version?: string;
|
||||
release?: string;
|
||||
startTimeISO?: string;
|
||||
endTimeISO?: string;
|
||||
completionStartTime?: string;
|
||||
level?: string;
|
||||
statusMessage?: string;
|
||||
promptName?: string;
|
||||
promptVersion?: string;
|
||||
modelName?: string;
|
||||
modelParameters?: Record<string, unknown>;
|
||||
providedUsageDetails?: Record<string, number>;
|
||||
providedCostDetails?: Record<string, number>;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
metadata: Record<string, unknown>;
|
||||
tags?: string[];
|
||||
public?: boolean;
|
||||
bookmarked?: boolean;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export type MaterializedInternalTrace = {
|
||||
rootSpanId: string;
|
||||
snapshots: InternalTraceSnapshot[];
|
||||
};
|
||||
|
||||
function isCreateEvent(type: string): boolean {
|
||||
return type.endsWith("-create");
|
||||
}
|
||||
|
||||
function getSnapshotType(eventType: string): "SPAN" | "GENERATION" {
|
||||
return eventType.startsWith("generation-") ? "GENERATION" : "SPAN";
|
||||
}
|
||||
|
||||
function getEventTime(
|
||||
event: ProcessedTraceEvent,
|
||||
body: Record<string, unknown>,
|
||||
): number {
|
||||
const candidates = [body.startTime, body.timestamp, event.timestamp];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string") {
|
||||
const parsed = new Date(candidate).getTime();
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getTimestampMs(timestamp?: string): number {
|
||||
if (!timestamp) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = new Date(timestamp).getTime();
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function sortEvents(events: ProcessedTraceEvent[]): ProcessedTraceEvent[] {
|
||||
return [...events].sort((left, right) => {
|
||||
const timeDelta =
|
||||
getEventTime(left, left.body) - getEventTime(right, right.body);
|
||||
|
||||
if (timeDelta !== 0) {
|
||||
return timeDelta;
|
||||
}
|
||||
|
||||
if (isCreateEvent(left.type) === isCreateEvent(right.type)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return isCreateEvent(left.type) ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
function flattenMetadata(value: unknown): {
|
||||
names: string[];
|
||||
values: Array<string | null | undefined>;
|
||||
} {
|
||||
const metadata = asRecord(value);
|
||||
return metadata
|
||||
? flattenJsonToPathArrays(metadata)
|
||||
: { names: [], values: [] };
|
||||
}
|
||||
|
||||
function mergeSnapshotEvent(
|
||||
snapshot: InternalTraceSnapshot,
|
||||
event: ProcessedTraceEvent,
|
||||
): InternalTraceSnapshot {
|
||||
const { body } = event;
|
||||
const startTime = asString(body.startTime);
|
||||
const timestamp = asString(body.timestamp) ?? asString(event.timestamp);
|
||||
const metadata = asRecord(body.metadata);
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
traceId: asString(body.traceId) ?? snapshot.traceId,
|
||||
parentSpanId: asString(body.parentObservationId) ?? snapshot.parentSpanId,
|
||||
type:
|
||||
snapshot.type === "GENERATION"
|
||||
? snapshot.type
|
||||
: getSnapshotType(event.type),
|
||||
name: asString(body.name) ?? snapshot.name,
|
||||
environment: asString(body.environment) ?? snapshot.environment,
|
||||
version: asString(body.version) ?? snapshot.version,
|
||||
release: asString(body.release) ?? snapshot.release,
|
||||
startTimeISO:
|
||||
startTime ?? snapshot.startTimeISO ?? timestamp ?? snapshot.startTimeISO,
|
||||
endTimeISO: asString(body.endTime) ?? snapshot.endTimeISO,
|
||||
completionStartTime:
|
||||
asString(body.completionStartTime) ?? snapshot.completionStartTime,
|
||||
level: asString(body.level) ?? snapshot.level,
|
||||
statusMessage: asString(body.statusMessage) ?? snapshot.statusMessage,
|
||||
promptName: asString(body.promptName) ?? snapshot.promptName,
|
||||
promptVersion:
|
||||
typeof body.promptVersion === "number"
|
||||
? body.promptVersion.toString()
|
||||
: (asString(body.promptVersion) ?? snapshot.promptVersion),
|
||||
modelName: asString(body.model) ?? snapshot.modelName,
|
||||
modelParameters: asRecord(body.modelParameters) ?? snapshot.modelParameters,
|
||||
providedUsageDetails:
|
||||
asNumberRecord(body.usageDetails) ??
|
||||
asNumberRecord(body.usage) ??
|
||||
snapshot.providedUsageDetails,
|
||||
providedCostDetails:
|
||||
asNumberRecord(body.costDetails) ?? snapshot.providedCostDetails,
|
||||
input:
|
||||
body.input !== undefined && body.input !== null
|
||||
? body.input
|
||||
: snapshot.input,
|
||||
output:
|
||||
body.output !== undefined && body.output !== null
|
||||
? body.output
|
||||
: snapshot.output,
|
||||
metadata: metadata
|
||||
? { ...snapshot.metadata, ...metadata }
|
||||
: snapshot.metadata,
|
||||
tags: asStringArray(body.tags) ?? snapshot.tags,
|
||||
public: asBoolean(body.public) ?? snapshot.public,
|
||||
bookmarked: asBoolean(body.bookmarked) ?? snapshot.bookmarked,
|
||||
userId: asString(body.userId) ?? snapshot.userId,
|
||||
sessionId: asString(body.sessionId) ?? snapshot.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function materializeInternalTrace(params: {
|
||||
processedEvents: ProcessedTraceEvent[];
|
||||
traceId: string;
|
||||
}): MaterializedInternalTrace {
|
||||
const { processedEvents, traceId } = params;
|
||||
const snapshots = new Map<string, InternalTraceSnapshot>();
|
||||
const traceCreateEvent = processedEvents.find(
|
||||
(e) => e.type === "trace-create",
|
||||
);
|
||||
const rootSpanId = asString(traceCreateEvent?.body.id) ?? traceId;
|
||||
|
||||
for (const event of sortEvents(processedEvents)) {
|
||||
const spanId = asString(event.body.id);
|
||||
|
||||
if (!spanId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingSnapshot =
|
||||
snapshots.get(spanId) ??
|
||||
({
|
||||
spanId,
|
||||
traceId: asString(event.body.traceId) ?? traceId,
|
||||
type: getSnapshotType(event.type),
|
||||
metadata: {},
|
||||
} satisfies InternalTraceSnapshot);
|
||||
|
||||
snapshots.set(spanId, mergeSnapshotEvent(existingSnapshot, event));
|
||||
}
|
||||
|
||||
const orderedSnapshots = [...snapshots.values()].sort((left, right) => {
|
||||
if (left.spanId === rootSpanId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (right.spanId === rootSpanId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (
|
||||
getTimestampMs(left.startTimeISO) - getTimestampMs(right.startTimeISO)
|
||||
);
|
||||
});
|
||||
|
||||
return { rootSpanId, snapshots: orderedSnapshots };
|
||||
}
|
||||
|
||||
export function buildInternalTraceEventInputs(params: {
|
||||
processedEvents: ProcessedTraceEvent[];
|
||||
traceId: string;
|
||||
projectId: string;
|
||||
experimentContext?: InternalTraceExperimentContext;
|
||||
}): {
|
||||
rootSpanId: string;
|
||||
eventInputs: InternalTraceEventInput[];
|
||||
} {
|
||||
const { processedEvents, traceId, projectId, experimentContext } = params;
|
||||
// Direct write uses original IDs (observation.id === trace.id for root).
|
||||
// The experiment backfill job skips traces already in events_core via LEFT ANTI JOIN,
|
||||
// so there's no deduplication concern between direct write and backfill.
|
||||
const { rootSpanId, snapshots } = materializeInternalTrace({
|
||||
processedEvents,
|
||||
traceId,
|
||||
});
|
||||
const rootSnapshot = snapshots.find((s) => s.spanId === rootSpanId);
|
||||
|
||||
if (!rootSnapshot) {
|
||||
return { rootSpanId, eventInputs: [] };
|
||||
}
|
||||
|
||||
const experimentMetadata = flattenMetadata(experimentContext?.metadata);
|
||||
const experimentItemMetadata = flattenMetadata(
|
||||
experimentContext?.itemMetadata,
|
||||
);
|
||||
const source = experimentContext
|
||||
? INTERNAL_TRACE_EXPERIMENT_EVENT_SOURCE
|
||||
: INTERNAL_TRACE_EVENT_SOURCE;
|
||||
|
||||
const eventInputs = snapshots.map((snapshot) => {
|
||||
const { toolDefinitions, toolArguments } = extractToolsFromObservation(
|
||||
snapshot.input,
|
||||
snapshot.output,
|
||||
);
|
||||
const toolCalls = convertCallsToArrays(toolArguments);
|
||||
const isRoot = snapshot.spanId === rootSpanId;
|
||||
|
||||
return {
|
||||
projectId,
|
||||
traceId,
|
||||
spanId: snapshot.spanId,
|
||||
parentSpanId: isRoot ? undefined : (snapshot.parentSpanId ?? rootSpanId),
|
||||
name:
|
||||
snapshot.name ??
|
||||
(snapshot.type === "GENERATION"
|
||||
? "generation"
|
||||
: (rootSnapshot.name ?? "span")),
|
||||
type: snapshot.type,
|
||||
environment: snapshot.environment ?? rootSnapshot.environment,
|
||||
version: snapshot.version ?? rootSnapshot.version,
|
||||
release: rootSnapshot.release,
|
||||
startTimeISO:
|
||||
snapshot.startTimeISO ??
|
||||
rootSnapshot.startTimeISO ??
|
||||
new Date().toISOString(),
|
||||
endTimeISO:
|
||||
snapshot.endTimeISO ??
|
||||
snapshot.startTimeISO ??
|
||||
rootSnapshot.endTimeISO ??
|
||||
rootSnapshot.startTimeISO ??
|
||||
new Date().toISOString(),
|
||||
completionStartTime: snapshot.completionStartTime,
|
||||
traceName: rootSnapshot.name,
|
||||
tags: rootSnapshot.tags ?? [],
|
||||
bookmarked: rootSnapshot.bookmarked,
|
||||
public: rootSnapshot.public,
|
||||
userId: rootSnapshot.userId,
|
||||
sessionId: rootSnapshot.sessionId,
|
||||
level: snapshot.level ?? "DEFAULT",
|
||||
statusMessage: snapshot.statusMessage,
|
||||
promptName: snapshot.promptName,
|
||||
promptVersion: snapshot.promptVersion,
|
||||
modelName: snapshot.modelName,
|
||||
modelParameters: snapshot.modelParameters,
|
||||
providedUsageDetails: snapshot.providedUsageDetails,
|
||||
providedCostDetails: snapshot.providedCostDetails,
|
||||
toolDefinitions: convertDefinitionsToMap(toolDefinitions),
|
||||
toolCalls: toolCalls.tool_calls,
|
||||
toolCallNames: toolCalls.tool_call_names,
|
||||
input:
|
||||
snapshot.input !== undefined
|
||||
? stringifyValue(snapshot.input)
|
||||
: undefined,
|
||||
output:
|
||||
snapshot.output !== undefined
|
||||
? stringifyValue(snapshot.output)
|
||||
: undefined,
|
||||
metadata: snapshot.metadata,
|
||||
source,
|
||||
experimentId: experimentContext?.id,
|
||||
experimentName: experimentContext?.name,
|
||||
experimentMetadataNames: experimentMetadata.names,
|
||||
experimentMetadataValues: experimentMetadata.values,
|
||||
experimentDescription: experimentContext?.description ?? undefined,
|
||||
experimentDatasetId: experimentContext?.datasetId,
|
||||
experimentItemId: experimentContext?.itemId,
|
||||
experimentItemVersion: experimentContext?.itemVersion,
|
||||
experimentItemRootSpanId: experimentContext ? rootSpanId : undefined,
|
||||
experimentItemExpectedOutput:
|
||||
experimentContext?.itemExpectedOutput !== undefined &&
|
||||
experimentContext?.itemExpectedOutput !== null
|
||||
? stringifyValue(experimentContext.itemExpectedOutput)
|
||||
: undefined,
|
||||
experimentItemMetadataNames: experimentItemMetadata.names,
|
||||
experimentItemMetadataValues: experimentItemMetadata.values,
|
||||
} satisfies InternalTraceEventInput;
|
||||
});
|
||||
|
||||
return { rootSpanId, eventInputs };
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
VertexAIConfigSchema,
|
||||
} from "../../interfaces/customLLMProviderConfigSchemas";
|
||||
import { JSONObjectSchema } from "../../utils/zod";
|
||||
import type {
|
||||
InternalTraceEventInput,
|
||||
InternalTraceExperimentContext,
|
||||
} from "./internalTraceEvents";
|
||||
|
||||
// disable lint as this is exported and used in web/worker
|
||||
|
||||
@@ -429,6 +433,7 @@ export type OpenAIModel = (typeof openAIModels)[number];
|
||||
export const anthropicModels = [
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-opus-4-7",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-5-20251101",
|
||||
@@ -533,16 +538,22 @@ export enum LangfuseInternalTraceEnvironment {
|
||||
LLMJudge = "langfuse-llm-as-a-judge",
|
||||
}
|
||||
|
||||
export type ProcessedTraceEvent = {
|
||||
type: string;
|
||||
timestamp: string;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Details of a generation extracted from traced events.
|
||||
* Used to pass generation information from internal tracing to callbacks.
|
||||
* Configuration for direct writing of trace events to the events table.
|
||||
* Used by internal tracing (prompt experiments, evaluations).
|
||||
*/
|
||||
export type GenerationDetails = {
|
||||
observationId: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
output: unknown;
|
||||
metadata: Record<string, unknown>;
|
||||
export type InternalEventsWriter = {
|
||||
experimentContext?: InternalTraceExperimentContext;
|
||||
write: (params: {
|
||||
rootSpanId: string;
|
||||
eventInputs: InternalTraceEventInput[];
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
export type TraceSinkParams = {
|
||||
@@ -561,8 +572,10 @@ export type TraceSinkParams = {
|
||||
version: number;
|
||||
};
|
||||
/**
|
||||
* Optional callback invoked after the generation events have been processed.
|
||||
* Called with merged generation details (from create + update events).
|
||||
* When provided, traced events are written directly to the events table,
|
||||
* bypassing the legacy traces/observations ingestion pipeline for the events write.
|
||||
* Used for internal tracing (prompt experiments, LLM-as-a-judge evaluations). Traced
|
||||
* events are still written to the legacy traces/observations tables.
|
||||
*/
|
||||
onGenerationComplete?: (details: GenerationDetails) => void;
|
||||
eventsWriter?: InternalEventsWriter;
|
||||
};
|
||||
|
||||
@@ -1315,18 +1315,25 @@ export class OtelIngestionProcessor {
|
||||
const keys = Object.keys(input).map((key) => key.replace(`${prefix}.`, ""));
|
||||
const useArray = keys.some((key) => key.match(/^\d+\./));
|
||||
|
||||
// Blocklist to prevent prototype pollution via crafted OTel attribute keys
|
||||
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
// Helper function to set a value at a nested path
|
||||
const setNestedValue = (obj: any, path: string[], value: unknown): void => {
|
||||
let current = obj;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i];
|
||||
if (DANGEROUS_KEYS.has(key)) return;
|
||||
if (!(key in current)) {
|
||||
// Check if next key is a number to decide if we need an array or object
|
||||
current[key] = /^\d+$/.test(path[i + 1]) ? [] : {};
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
current[path[path.length - 1]] = value;
|
||||
const finalKey = path[path.length - 1];
|
||||
if (!DANGEROUS_KEYS.has(finalKey)) {
|
||||
current[finalKey] = value;
|
||||
}
|
||||
};
|
||||
|
||||
if (useArray) {
|
||||
@@ -1335,7 +1342,7 @@ export class OtelIngestionProcessor {
|
||||
const pathParts = key.split(".");
|
||||
const index = parseInt(pathParts[0], 10);
|
||||
if (!result[index]) {
|
||||
result[index] = {};
|
||||
result[index] = Object.create(null);
|
||||
}
|
||||
if (pathParts.length === 2) {
|
||||
// Simple case: 0.content -> result[0].content
|
||||
@@ -1351,7 +1358,7 @@ export class OtelIngestionProcessor {
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
const result: Record<string, unknown> = {};
|
||||
const result: Record<string, unknown> = Object.create(null);
|
||||
for (const key of keys) {
|
||||
const pathParts = key.split(".");
|
||||
if (pathParts.length === 1) {
|
||||
@@ -2476,51 +2483,120 @@ export class OtelIngestionProcessor {
|
||||
}
|
||||
|
||||
if (instrumentationScopeName === "pydantic-ai") {
|
||||
const inputTokens = attributes["gen_ai.usage.input_tokens"];
|
||||
const outputTokens = attributes["gen_ai.usage.output_tokens"];
|
||||
const cacheReadTokens =
|
||||
attributes["gen_ai.usage.cache_read_tokens"] ??
|
||||
attributes["gen_ai.usage.details.cache_read_input_tokens"];
|
||||
const cacheWriteTokens =
|
||||
attributes["gen_ai.usage.cache_write_tokens"] ??
|
||||
attributes["gen_ai.usage.details.cache_creation_input_tokens"];
|
||||
|
||||
return {
|
||||
input: inputTokens,
|
||||
output: outputTokens,
|
||||
input_cache_read: cacheReadTokens,
|
||||
input_cache_creation: cacheWriteTokens,
|
||||
};
|
||||
const usageDetails = this.extractGenericGenAiUsageDetails(attributes);
|
||||
if (Object.keys(usageDetails).length > 0) return usageDetails;
|
||||
}
|
||||
|
||||
return this.extractGenericGenAiUsageDetails(attributes);
|
||||
}
|
||||
|
||||
private extractGenericGenAiUsageDetails(
|
||||
attributes: Record<string, unknown>,
|
||||
): Record<string, number> {
|
||||
const usageDetails = Object.keys(attributes).filter(
|
||||
(key) =>
|
||||
(key.startsWith("gen_ai.usage.") && key !== "gen_ai.usage.cost") ||
|
||||
key.startsWith("llm.token_count"),
|
||||
key.startsWith("llm.token_count."),
|
||||
);
|
||||
|
||||
const usageDetailKeyMapping: Record<string, string> = {
|
||||
prompt_tokens: "input",
|
||||
completion_tokens: "output",
|
||||
total_tokens: "total",
|
||||
input_tokens: "input",
|
||||
output_tokens: "output",
|
||||
prompt: "input",
|
||||
completion: "output",
|
||||
};
|
||||
if (usageDetails.length === 0) return {};
|
||||
|
||||
return usageDetails.reduce((acc: any, key) => {
|
||||
const usageDetailKey = key
|
||||
.replace("gen_ai.usage.", "")
|
||||
.replace("llm.token_count.", "");
|
||||
const mappedUsageDetailKey =
|
||||
usageDetailKeyMapping[usageDetailKey] ?? usageDetailKey;
|
||||
const value = Number(attributes[key]);
|
||||
if (!Number.isNaN(value)) {
|
||||
acc[mappedUsageDetailKey] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const rawUsageDetails = usageDetails.reduce(
|
||||
(acc: Record<string, number>, key) => {
|
||||
const usageDetailKey = key
|
||||
.replace("gen_ai.usage.", "")
|
||||
.replace("llm.token_count.", "");
|
||||
const value = Number(attributes[key]);
|
||||
|
||||
if (!Number.isNaN(value)) {
|
||||
acc[usageDetailKey] = value;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const inputTokens =
|
||||
rawUsageDetails["prompt_tokens"] ??
|
||||
rawUsageDetails["input_tokens"] ??
|
||||
rawUsageDetails["prompt"];
|
||||
const outputTokens =
|
||||
rawUsageDetails["completion_tokens"] ??
|
||||
rawUsageDetails["output_tokens"] ??
|
||||
rawUsageDetails["completion"];
|
||||
const totalTokens =
|
||||
rawUsageDetails["total_tokens"] ?? rawUsageDetails["total"];
|
||||
const cacheReadTokens =
|
||||
rawUsageDetails["cache_read.input_tokens"] ??
|
||||
rawUsageDetails["cache_read_tokens"] ??
|
||||
rawUsageDetails["details.cache_read_tokens"] ??
|
||||
rawUsageDetails["details.cache_read_input_tokens"];
|
||||
const cacheCreationTokens =
|
||||
rawUsageDetails["cache_creation.input_tokens"] ??
|
||||
rawUsageDetails["cache_write_tokens"] ??
|
||||
rawUsageDetails["details.cache_write_tokens"] ??
|
||||
rawUsageDetails["details.cache_creation_input_tokens"];
|
||||
|
||||
const normalizedUsageDetails = Object.entries(rawUsageDetails).reduce(
|
||||
(acc: Record<string, number>, [key, value]) => {
|
||||
if (
|
||||
[
|
||||
"prompt_tokens",
|
||||
"input_tokens",
|
||||
"prompt",
|
||||
"completion_tokens",
|
||||
"output_tokens",
|
||||
"completion",
|
||||
"total_tokens",
|
||||
"total",
|
||||
"cache_read.input_tokens",
|
||||
"cache_read_tokens",
|
||||
"details.cache_read_tokens",
|
||||
"details.cache_read_input_tokens",
|
||||
"cache_creation.input_tokens",
|
||||
"cache_write_tokens",
|
||||
"details.cache_write_tokens",
|
||||
"details.cache_creation_input_tokens",
|
||||
].includes(key)
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const normalizedKey = key.startsWith("details.")
|
||||
? key.replace("details.", "")
|
||||
: key;
|
||||
|
||||
acc[normalizedKey] = value;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
if (inputTokens !== undefined) {
|
||||
normalizedUsageDetails.input = Math.max(
|
||||
inputTokens - (cacheReadTokens ?? 0) - (cacheCreationTokens ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
if (outputTokens !== undefined) {
|
||||
normalizedUsageDetails.output = outputTokens;
|
||||
}
|
||||
|
||||
if (totalTokens !== undefined) {
|
||||
normalizedUsageDetails.total = totalTokens;
|
||||
}
|
||||
|
||||
if (cacheReadTokens !== undefined) {
|
||||
normalizedUsageDetails.input_cached_tokens = cacheReadTokens;
|
||||
}
|
||||
|
||||
if (cacheCreationTokens !== undefined) {
|
||||
normalizedUsageDetails.input_cache_creation = cacheCreationTokens;
|
||||
}
|
||||
|
||||
return normalizedUsageDetails;
|
||||
}
|
||||
|
||||
private extractCostDetails(
|
||||
|
||||
@@ -181,6 +181,7 @@ const FIELD_SETS = {
|
||||
"userId",
|
||||
"sessionId",
|
||||
"traceName",
|
||||
"tags",
|
||||
"toolDefinitions",
|
||||
"toolCalls",
|
||||
"toolCallNames",
|
||||
@@ -217,6 +218,7 @@ const FIELD_SETS = {
|
||||
"userId",
|
||||
"sessionId",
|
||||
"traceName",
|
||||
"tags",
|
||||
],
|
||||
calculated: ["latency", "timeToFirstToken"],
|
||||
io: ["input", "output"],
|
||||
@@ -239,6 +241,12 @@ const FIELD_SETS = {
|
||||
"level",
|
||||
"statusMessage",
|
||||
"version",
|
||||
"userId",
|
||||
"sessionId",
|
||||
"traceName",
|
||||
"tags",
|
||||
"bookmarked",
|
||||
"public",
|
||||
"toolDefinitions",
|
||||
"toolCalls",
|
||||
"toolCallNames",
|
||||
@@ -353,6 +361,9 @@ const FIELD_SETS = {
|
||||
"toolDefinitions",
|
||||
"toolCalls",
|
||||
"toolCallNames",
|
||||
"experimentId",
|
||||
"experimentItemRootSpanId",
|
||||
"experimentItemExpectedOutput",
|
||||
],
|
||||
|
||||
// Experiment items field set
|
||||
@@ -1710,6 +1721,11 @@ const EXPERIMENTS_AGGREGATION_FIELDS = {
|
||||
"groupUniqArrayIf(tuple(e.prompt_name, e.prompt_version), e.prompt_name != '') AS prompts",
|
||||
experimentMetadata:
|
||||
"any(mapFromArrays(e.experiment_metadata_names, e.experiment_metadata_values)) AS experiment_metadata",
|
||||
|
||||
// Metrics fields
|
||||
totalCost: "SUM(e.total_cost) AS total_cost",
|
||||
latencyAvg:
|
||||
"avgIf(date_diff('millisecond', e.start_time, e.end_time), e.span_id = e.experiment_item_root_span_id AND e.end_time IS NOT NULL) AS latency_avg",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -1728,6 +1744,7 @@ const EXPERIMENTS_AGGREGATION_FIELD_SETS = {
|
||||
"prompts",
|
||||
"experimentMetadata",
|
||||
] as const,
|
||||
metrics: ["experimentId", "totalCost", "latencyAvg"] as const,
|
||||
} as const;
|
||||
|
||||
export type ExperimentsAggregationFieldSetName =
|
||||
@@ -1736,9 +1753,9 @@ export type ExperimentsAggregationFieldSetName =
|
||||
/**
|
||||
* ExperimentsAggregationQueryBuilder - Aggregates events by (experiment_id, project_id).
|
||||
*
|
||||
* For metrics requiring trace-level aggregation first (cost, latency), use CTEQueryBuilder
|
||||
* to wrap a trace CTE and re-aggregate at experiment level with selectRaw() + groupBy().
|
||||
* selectRaw() is intentionally used for explicit two-level aggregation semantics.
|
||||
* Use the "metrics" field set for cost and latency aggregations:
|
||||
* - Cost: SUM of all event costs for the experiment
|
||||
* - Latency: AVG of root span duration (where span_id = experiment_item_root_span_id)
|
||||
*/
|
||||
export class ExperimentsAggregationQueryBuilder extends BaseEventsQueryBuilder<
|
||||
typeof EXPERIMENTS_AGGREGATION_FIELDS
|
||||
|
||||
@@ -5,6 +5,7 @@ import { InvalidRequestError } from "../../../errors";
|
||||
import { isValidTableName } from "../../clickhouse/schemaUtils";
|
||||
import { logger } from "../../logger";
|
||||
import {
|
||||
findUiColumnMapping,
|
||||
type ColumnDefinition,
|
||||
type UiColumnMappings,
|
||||
} from "../../../tableDefinitions";
|
||||
@@ -173,10 +174,7 @@ const matchAndVerifyTracesUiColumn = (
|
||||
uiTableDefinitions: UiColumnMappings,
|
||||
) => {
|
||||
// tries to match the column name to the clickhouse table name
|
||||
const uiTable = uiTableDefinitions.find(
|
||||
(col) =>
|
||||
col.uiTableName === filter.column || col.uiTableId === filter.column, // matches on the NAME of the column in the UI.
|
||||
);
|
||||
const uiTable = findUiColumnMapping(uiTableDefinitions, filter.column);
|
||||
|
||||
if (!uiTable) {
|
||||
const errorMessage = `Column ${filter.column} does not match a UI / CH table mapping.`;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import z from "zod";
|
||||
import { OrderByState } from "../../../interfaces/orderBy";
|
||||
import { UiColumnMappings } from "../../../tableDefinitions";
|
||||
import {
|
||||
findUiColumnMapping,
|
||||
UiColumnMappings,
|
||||
} from "../../../tableDefinitions";
|
||||
import { InvalidRequestError } from "../../../errors";
|
||||
import { logger } from "../../logger";
|
||||
import type { OrderByDirection, OrderByEntry } from "./event-query-builder";
|
||||
@@ -30,9 +33,7 @@ export function orderByToClickhouseSql(
|
||||
Boolean(o),
|
||||
)) {
|
||||
// Get column definition to map column to internal name, e.g. "t.id"
|
||||
const col = tableColumns.find(
|
||||
(c) => c.uiTableName === ob.column || c.uiTableId === ob.column,
|
||||
);
|
||||
const col = findUiColumnMapping(tableColumns, ob.column);
|
||||
|
||||
if (!col) {
|
||||
logger.warn(`Invalid order by column: ${ob.column}`);
|
||||
@@ -83,9 +84,7 @@ export function orderByToEntries(
|
||||
for (const ob of orderBy.filter((o): o is OrderByStateNotNull =>
|
||||
Boolean(o),
|
||||
)) {
|
||||
const col = tableColumns.find(
|
||||
(c) => c.uiTableName === ob.column || c.uiTableId === ob.column,
|
||||
);
|
||||
const col = findUiColumnMapping(tableColumns, ob.column);
|
||||
|
||||
if (!col) {
|
||||
logger.warn(`Invalid order by column: ${ob.column}`);
|
||||
|
||||
@@ -42,12 +42,25 @@ export class BlobStorageIntegrationQueue {
|
||||
|
||||
if (BlobStorageIntegrationQueue.instance) {
|
||||
logger.debug("Scheduling jobs for BlobStorageIntegrationQueue");
|
||||
// Remove the old hourly cron pattern — BullMQ keys repeatable jobs by
|
||||
// name + pattern, so changing the pattern creates a second schedule
|
||||
// while the old one keeps firing.
|
||||
BlobStorageIntegrationQueue.instance
|
||||
.removeRepeatable(QueueJobs.BlobStorageIntegrationJob, {
|
||||
pattern: "20 * * * *",
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(
|
||||
"Error removing legacy BlobStorageIntegrationJob schedule",
|
||||
err,
|
||||
);
|
||||
});
|
||||
BlobStorageIntegrationQueue.instance
|
||||
.add(
|
||||
QueueJobs.BlobStorageIntegrationJob,
|
||||
{},
|
||||
{
|
||||
repeat: { pattern: "20 * * * *" }, // every hour at 20 minutes past
|
||||
repeat: { pattern: "*/20 * * * *" }, // every 20 minutes
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
|
||||
@@ -51,21 +51,40 @@ export class CloudUsageMeteringQueue {
|
||||
jobId: "cloud-usage-metering-recurring",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
CloudUsageMeteringQueue.instance.add(
|
||||
QueueJobs.CloudUsageMeteringJob,
|
||||
{},
|
||||
{
|
||||
// Run at minute 5 of every hour (e.g. 1:05, 2:05, 3:05, etc)
|
||||
repeat: { pattern: "5 * * * *" },
|
||||
},
|
||||
);
|
||||
CloudUsageMeteringQueue.instance
|
||||
.add(
|
||||
QueueJobs.CloudUsageMeteringJob,
|
||||
{},
|
||||
{
|
||||
// Run at minute 5 of every hour (e.g. 1:05, 2:05, 3:05, etc)
|
||||
repeat: { pattern: "5 * * * *" },
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
logger.error(
|
||||
"[CloudUsageMeteringQueue] Failed to schedule recurring job",
|
||||
err,
|
||||
);
|
||||
});
|
||||
|
||||
logger.info("[CloudUsageMeteringQueue] Scheduling bootstrap job", {
|
||||
jobId: "cloud-usage-metering-bootstrap",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// Bootstrap job to run immediately on startup
|
||||
CloudUsageMeteringQueue.instance.add(QueueJobs.CloudUsageMeteringJob, {});
|
||||
// Bootstrap job to run immediately on startup. Safe to enqueue from
|
||||
// multiple replicas: the handler (handleCloudUsageMeteringJob) is
|
||||
// idempotent — it acquires a DB lock via optimistic concurrency on the
|
||||
// cronJobs row and exits early ("not due yet") if the metering interval
|
||||
// hasn't elapsed. Duplicate jobs are processed sequentially (concurrency: 1)
|
||||
// and no-op harmlessly.
|
||||
CloudUsageMeteringQueue.instance
|
||||
.add(QueueJobs.CloudUsageMeteringJob, {})
|
||||
.catch((err) => {
|
||||
logger.error(
|
||||
"[CloudUsageMeteringQueue] Failed to schedule bootstrap job",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return CloudUsageMeteringQueue.instance;
|
||||
|
||||
@@ -115,6 +115,7 @@ export const eventsObservationRecordReadSchema =
|
||||
user_id: z.string().nullish(),
|
||||
session_id: z.string().nullish(),
|
||||
trace_name: z.string().nullish(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
bookmarked: z.boolean().optional(),
|
||||
public: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -51,16 +51,16 @@ import {
|
||||
queryClickhouse,
|
||||
queryClickhouseStream,
|
||||
} from "./clickhouse";
|
||||
import { ObservationRecordReadType, TraceRecordReadType } from "./definitions";
|
||||
import {
|
||||
EventsObservationRecordReadType,
|
||||
TraceRecordReadType,
|
||||
} from "./definitions";
|
||||
import type { AnalyticsObservationEvent } from "../analytics-integrations/types";
|
||||
import {
|
||||
ObservationsTableQueryResult,
|
||||
ObservationTableQuery,
|
||||
} from "./observations";
|
||||
import {
|
||||
convertEventsObservation,
|
||||
convertObservation,
|
||||
} from "./observations_converters";
|
||||
import { convertEventsObservation } from "./observations_converters";
|
||||
import {
|
||||
EventsQueryBuilder,
|
||||
CTEQueryBuilder,
|
||||
@@ -193,18 +193,22 @@ async function enrichObservationsWithModelData(
|
||||
async function enrichObservationsWithTraceFields(
|
||||
observationRecords: Array<EventsObservation & ObservationPriceFields>,
|
||||
): Promise<FullEventsObservations> {
|
||||
return observationRecords.map((o) => {
|
||||
return observationRecords.map((observation) => {
|
||||
// Remove raw tags field as this is re-mapped to traceTags
|
||||
const { tags: _tags, ...observationWithoutRawTags } = observation;
|
||||
return {
|
||||
...o,
|
||||
traceTags: [], // TODO pull from PG
|
||||
...observationWithoutRawTags,
|
||||
traceTags: observation.tags ?? [],
|
||||
traceTimestamp: null,
|
||||
toolDefinitions: o.toolDefinitions ?? null,
|
||||
toolCalls: o.toolCalls ?? null,
|
||||
toolDefinitions: observation.toolDefinitions ?? null,
|
||||
toolCalls: observation.toolCalls ?? null,
|
||||
// Compute counts from actual data for events table
|
||||
toolDefinitionsCount: o.toolDefinitions
|
||||
? Object.keys(o.toolDefinitions).length
|
||||
toolDefinitionsCount: observation.toolDefinitions
|
||||
? Object.keys(observation.toolDefinitions).length
|
||||
: null,
|
||||
toolCallsCount: observation.toolCalls
|
||||
? observation.toolCalls.length
|
||||
: null,
|
||||
toolCallsCount: o.toolCalls ? o.toolCalls.length : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -609,9 +613,19 @@ export const getObservationByIdFromEventsTable = async ({
|
||||
renderingProps,
|
||||
preferredClickhouseService: preferredClickhouseService ?? "EventsReadOnly",
|
||||
});
|
||||
const mapped = records.map((record) =>
|
||||
convertObservation(record, renderingProps),
|
||||
);
|
||||
const mapped = records.map((record) => {
|
||||
// Remove raw tags field as this is re-mapped to traceTags
|
||||
const { tags, ...converted } = convertEventsObservation(
|
||||
record,
|
||||
renderingProps,
|
||||
true,
|
||||
);
|
||||
|
||||
return {
|
||||
...converted,
|
||||
traceTags: tags ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
mapped.forEach((observation) => {
|
||||
recordDistribution(
|
||||
@@ -682,7 +696,7 @@ async function getObservationByIdFromEventsTableInternal({
|
||||
|
||||
const { query, params } = queryBuilder.buildWithParams();
|
||||
|
||||
return await queryClickhouse<ObservationRecordReadType>({
|
||||
return await queryClickhouse<EventsObservationRecordReadType>({
|
||||
query,
|
||||
params,
|
||||
tags: {
|
||||
@@ -1072,7 +1086,7 @@ async function getObservationsCountFromEventsTableForPublicApiInternal(
|
||||
*/
|
||||
export const getObservationsFromEventsTableForPublicApi = async (
|
||||
opts: Omit<PublicApiObservationsQuery, "fields">,
|
||||
): Promise<Array<Observation & ObservationPriceFields>> => {
|
||||
): Promise<Array<EventsObservation & ObservationPriceFields>> => {
|
||||
const { projectId } = opts;
|
||||
|
||||
// Build query with filters and common CTEs
|
||||
@@ -1090,12 +1104,15 @@ export const getObservationsFromEventsTableForPublicApi = async (
|
||||
projectId,
|
||||
queryBuilder,
|
||||
);
|
||||
return await enrichObservationsWithModelData(
|
||||
|
||||
const observations = await enrichObservationsWithModelData(
|
||||
observationRecords,
|
||||
opts.projectId,
|
||||
opts.parseIoAsJson ?? true, // V1 API: default to parsing JSON (backwards compatibility)
|
||||
null, // V1 API: no field groups, return complete observations
|
||||
);
|
||||
|
||||
return observations;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -3193,3 +3210,134 @@ export const getSessionMetricsFromEvents = async (props: {
|
||||
total_observations: Number(row.total_observations),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* SDK metadata detection result.
|
||||
* isOtel is always present, other fields are optional (only when non-empty).
|
||||
*/
|
||||
export type SdkMetadata = {
|
||||
isOtel: boolean;
|
||||
name?: string;
|
||||
version?: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract SDK info from v3 metadata object.
|
||||
* - Old (nested): `scope: {name, version}`, `resourceAttributes: {"telemetry.sdk.language": ...}`
|
||||
*/
|
||||
function extractSdkInfoFromMetadata(metadata: Record<string, string>): {
|
||||
name?: string;
|
||||
version?: string;
|
||||
language?: string;
|
||||
telemetrySdkName?: string;
|
||||
} {
|
||||
try {
|
||||
const scopeJson = metadata["scope"];
|
||||
const resourceJson = metadata["resourceAttributes"];
|
||||
|
||||
const scope = scopeJson ? JSON.parse(scopeJson) : null;
|
||||
const resource = resourceJson ? JSON.parse(resourceJson) : null;
|
||||
|
||||
const name = scope?.name;
|
||||
const version = scope?.version;
|
||||
const language = resource?.["telemetry.sdk.language"];
|
||||
const telemetrySdkName = resource?.["telemetry.sdk.name"];
|
||||
|
||||
return {
|
||||
...(name && { name }),
|
||||
...(version && { version }),
|
||||
...(language && { language }),
|
||||
...(telemetrySdkName && { telemetrySdkName }),
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers SDK details from the most recent event in the past 7 days containing Langfuse SDK metadata attributes.
|
||||
*
|
||||
* Detection priority:
|
||||
* - v4+: Direct columns (scope_name, scope_version, telemetry_sdk_language)
|
||||
* - v3: Nested JSON in metadata (`scope: {name, version}`)
|
||||
* - v2 and older: No SDK metadata → returns isOtel: false
|
||||
*
|
||||
* Returns the most recent matching event's SDK info. Projects with no events
|
||||
* in the past 7 days return isOtel: false (acceptable for inactive projects).
|
||||
*/
|
||||
export async function getLatestSdkVersionInfoFromEvents(params: {
|
||||
projectId: string;
|
||||
}): Promise<SdkMetadata> {
|
||||
const { projectId } = params;
|
||||
|
||||
// Time filter: last 7 days
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const filter = new FilterList([
|
||||
new DateTimeFilter({
|
||||
clickhouseTable: "events_proto",
|
||||
field: "start_time",
|
||||
operator: ">=",
|
||||
value: sevenDaysAgo,
|
||||
tablePrefix: "e",
|
||||
}),
|
||||
]);
|
||||
|
||||
const builder = new EventsQueryBuilder({ projectId })
|
||||
.selectRaw("e.scope_name", "e.scope_version", "e.telemetry_sdk_language")
|
||||
.selectMetadataExpanded([]) // Full metadata values from events_full
|
||||
.applyFilters(filter)
|
||||
// OR condition: v4 has scope_name column, v3 has scope in metadata
|
||||
.where({
|
||||
query: "e.scope_name != '' OR hasAny(e.metadata_names, ['scope'])",
|
||||
params: {},
|
||||
})
|
||||
.orderByDefault()
|
||||
.limit(1);
|
||||
|
||||
const { query, params: queryParams } = builder.buildWithParams();
|
||||
|
||||
const result = await queryClickhouse<{
|
||||
scope_name: string;
|
||||
scope_version: string;
|
||||
telemetry_sdk_language: string;
|
||||
metadata: Record<string, string>;
|
||||
}>({
|
||||
query,
|
||||
params: queryParams,
|
||||
tags: {
|
||||
feature: "sdk-metadata-detection",
|
||||
kind: "sdkMetadata",
|
||||
projectId,
|
||||
},
|
||||
preferredClickhouseService: "EventsReadOnly",
|
||||
});
|
||||
|
||||
if (result.length === 0) {
|
||||
return { isOtel: false };
|
||||
}
|
||||
const row = result[0];
|
||||
|
||||
// Prefer direct columns (v4), fall back to metadata (v3)
|
||||
if (row.scope_name) {
|
||||
return {
|
||||
isOtel: true,
|
||||
...(row.scope_name && { name: row.scope_name }),
|
||||
...(row.scope_version && { version: row.scope_version }),
|
||||
...(row.telemetry_sdk_language && {
|
||||
language: row.telemetry_sdk_language,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to metadata extraction for v3 and raw OTel (e.g., Vercel AI SDK)
|
||||
const { telemetrySdkName, ...sdkInfo } = extractSdkInfoFromMetadata(
|
||||
row.metadata ?? {},
|
||||
);
|
||||
return {
|
||||
isOtel:
|
||||
telemetrySdkName === "opentelemetry" ||
|
||||
Boolean(sdkInfo.name || sdkInfo.version || sdkInfo.language),
|
||||
...sdkInfo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
eventsExperimentsAggregation,
|
||||
eventsScoresAggregation,
|
||||
eventsTracesScoresAggregation,
|
||||
eventsTracesAggregation,
|
||||
} from "../queries/clickhouse-sql/query-fragments";
|
||||
import { extractTimeFilter, queryClickhouse } from "../repositories";
|
||||
import { parseClickhouseUTCDateTimeFormat } from "../repositories/clickhouse";
|
||||
@@ -174,27 +173,17 @@ export const getExperimentMetricsFromEvents = async (props: {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tracesBuilder = eventsTracesAggregation({
|
||||
// Use eventsExperimentsAggregation with "metrics" field set for simplified aggregation
|
||||
const queryBuilder = eventsExperimentsAggregation({
|
||||
projectId: props.projectId,
|
||||
}).whereRaw("e.experiment_id IN ({experimentIds: Array(String)})", {
|
||||
fieldSet: "metrics",
|
||||
experimentIds: props.experimentIds,
|
||||
});
|
||||
|
||||
// Build the final query
|
||||
const queryBuilder = new CTEQueryBuilder()
|
||||
.withCTEFromBuilder("traces_agg", tracesBuilder)
|
||||
.from("traces_agg", "ta")
|
||||
.select(
|
||||
"ta.experiment_id AS experiment_id",
|
||||
"SUM(ta.total_cost) AS total_cost",
|
||||
"AVG(ta.latency_milliseconds) AS latency_avg",
|
||||
)
|
||||
.groupBy("ta.project_id", "ta.experiment_id");
|
||||
|
||||
const { query, params } = queryBuilder.buildWithParams();
|
||||
|
||||
const res = await measureAndReturn({
|
||||
operationName: "getExperimentsFromEventsGeneric",
|
||||
operationName: "getExperimentMetricsFromEvents",
|
||||
projectId: props.projectId,
|
||||
input: {
|
||||
params,
|
||||
@@ -202,7 +191,7 @@ export const getExperimentMetricsFromEvents = async (props: {
|
||||
feature: "experiments",
|
||||
type: "experiments-table",
|
||||
projectId: props.projectId,
|
||||
operation_name: `getExperimentMetricsFromEvents`,
|
||||
operation_name: "getExperimentMetricsFromEvents",
|
||||
},
|
||||
},
|
||||
fn: async (input) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
observationsTableUiColumnDefinitions,
|
||||
} from "../tableMappings";
|
||||
import { OrderByState } from "../../interfaces/orderBy";
|
||||
import { matchesUiColumnMapping } from "../../tableDefinitions";
|
||||
import { getTracesByIds } from "./traces";
|
||||
import { measureAndReturn } from "../clickhouse/measureAndReturn";
|
||||
import {
|
||||
@@ -704,15 +705,14 @@ const getObservationsTableInternal = async <T>(
|
||||
|
||||
// query optimisation: joining traces onto observations is expensive. Hence, only join if the UI table contains filters on traces.
|
||||
const traceTableFilter = filter.filter((f) =>
|
||||
observationsTableTraceUiColumnDefinitions.some(
|
||||
(c) => c.uiTableId === f.column || c.uiTableName === f.column,
|
||||
observationsTableTraceUiColumnDefinitions.some((c) =>
|
||||
matchesUiColumnMapping(c, f.column),
|
||||
),
|
||||
);
|
||||
|
||||
const orderByTraces = orderBy
|
||||
? observationsTableTraceUiColumnDefinitions.some(
|
||||
(c) =>
|
||||
c.uiTableId === orderBy.column || c.uiTableName === orderBy.column,
|
||||
? observationsTableTraceUiColumnDefinitions.some((c) =>
|
||||
matchesUiColumnMapping(c, orderBy.column),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
@@ -1418,6 +1418,10 @@ export const deleteObservationsOlderThanDays = async (
|
||||
export const getObservationsWithPromptName = async (
|
||||
projectId: string,
|
||||
promptNames: string[],
|
||||
{
|
||||
fromTimestamp,
|
||||
toTimestamp,
|
||||
}: { fromTimestamp?: Date; toTimestamp?: Date } = {},
|
||||
) => {
|
||||
const query = `
|
||||
SELECT uniq(id) as count, prompt_name
|
||||
@@ -1425,6 +1429,8 @@ export const getObservationsWithPromptName = async (
|
||||
WHERE project_id = {projectId: String}
|
||||
AND prompt_name IN ({promptNames: Array(String)})
|
||||
AND prompt_name IS NOT NULL
|
||||
${fromTimestamp ? "AND start_time >= {fromTimestamp: DateTime64(3)}" : ""}
|
||||
${toTimestamp ? "AND start_time <= {toTimestamp: DateTime64(3)}" : ""}
|
||||
GROUP BY prompt_name
|
||||
`;
|
||||
const rows = await queryClickhouse<{ count: string; prompt_name: string }>({
|
||||
@@ -1432,6 +1438,12 @@ export const getObservationsWithPromptName = async (
|
||||
params: {
|
||||
projectId,
|
||||
promptNames,
|
||||
fromTimestamp: fromTimestamp
|
||||
? convertDateToClickhouseDateTime(fromTimestamp)
|
||||
: undefined,
|
||||
toTimestamp: toTimestamp
|
||||
? convertDateToClickhouseDateTime(toTimestamp)
|
||||
: undefined,
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
@@ -1450,6 +1462,10 @@ export const getObservationsWithPromptName = async (
|
||||
export const getObservationMetricsForPrompts = async (
|
||||
projectId: string,
|
||||
promptIds: string[],
|
||||
{
|
||||
fromTimestamp,
|
||||
toTimestamp,
|
||||
}: { fromTimestamp?: Date; toTimestamp?: Date } = {},
|
||||
) => {
|
||||
const query = `
|
||||
WITH latencies AS
|
||||
@@ -1468,6 +1484,8 @@ export const getObservationMetricsForPrompts = async (
|
||||
AND (prompt_name IS NOT NULL)
|
||||
AND project_id={projectId: String}
|
||||
AND prompt_id IN ({promptIds: Array(String)})
|
||||
${fromTimestamp ? "AND start_time >= {fromTimestamp: DateTime64(3)}" : ""}
|
||||
${toTimestamp ? "AND start_time <= {toTimestamp: DateTime64(3)}" : ""}
|
||||
)
|
||||
SELECT
|
||||
count(*) AS count,
|
||||
@@ -1500,6 +1518,12 @@ export const getObservationMetricsForPrompts = async (
|
||||
params: {
|
||||
projectId,
|
||||
promptIds,
|
||||
...(fromTimestamp
|
||||
? { fromTimestamp: convertDateToClickhouseDateTime(fromTimestamp) }
|
||||
: {}),
|
||||
...(toTimestamp
|
||||
? { toTimestamp: convertDateToClickhouseDateTime(toTimestamp) }
|
||||
: {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
@@ -1995,6 +2019,7 @@ export const getObservationCountsByProjectAndDay = async ({
|
||||
startDate: convertDateToClickhouseDateTime(startDate),
|
||||
endDate: convertDateToClickhouseDateTime(endDate),
|
||||
},
|
||||
clickhouseConfigs: { request_timeout: 120_000 },
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "observation",
|
||||
|
||||
@@ -411,6 +411,7 @@ export function convertEventsObservation(
|
||||
userId: record.user_id ?? null,
|
||||
sessionId: record.session_id ?? null,
|
||||
traceName: record.trace_name ?? null,
|
||||
tags: record.tags ?? [],
|
||||
bookmarked: record.bookmarked,
|
||||
public: record.public,
|
||||
};
|
||||
|
||||
@@ -52,6 +52,10 @@ import {
|
||||
eventsExperiments,
|
||||
} from "../queries/clickhouse-sql/query-fragments";
|
||||
import { scoresTableCols } from "../../tableDefinitions/scoresTable";
|
||||
import {
|
||||
findUiColumnMapping,
|
||||
matchesUiColumnMapping,
|
||||
} from "../../tableDefinitions";
|
||||
|
||||
export const searchExistingAnnotationScore = async (
|
||||
projectId: string,
|
||||
@@ -751,10 +755,8 @@ export const getScoresGroupedByNameSourceType = async ({
|
||||
|
||||
// Extract event-level filter entries from the frontend filter state
|
||||
const eventFilterState = filter.filter((filterEntry) =>
|
||||
scoresEventsFilterMapping.some(
|
||||
(col) =>
|
||||
col.uiTableName === filterEntry.column ||
|
||||
col.uiTableId === filterEntry.column,
|
||||
scoresEventsFilterMapping.some((col) =>
|
||||
matchesUiColumnMapping(col, filterEntry.column),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1294,21 +1296,21 @@ const getScoresUiGenericFromEvents = async <T>(props: {
|
||||
|
||||
// Trace-level filter entries from the frontend filter state
|
||||
const traceFilterState = filter.filter((filterEntry) =>
|
||||
scoresTraceFilterEventsMapping.some(
|
||||
(col) =>
|
||||
col.uiTableName === filterEntry.column ||
|
||||
col.uiTableId === filterEntry.column,
|
||||
scoresTraceFilterEventsMapping.some((col) =>
|
||||
matchesUiColumnMapping(col, filterEntry.column),
|
||||
),
|
||||
);
|
||||
|
||||
const orderByColumn = orderBy
|
||||
? scoresTableUiColumnDefinitionsFromEvents.find(
|
||||
(c) =>
|
||||
(c.uiTableName === orderBy.column ||
|
||||
c.uiTableId === orderBy.column) &&
|
||||
c.clickhouseTableName === "traces",
|
||||
const matchedOrderByColumn = orderBy
|
||||
? findUiColumnMapping(
|
||||
scoresTableUiColumnDefinitionsFromEvents,
|
||||
orderBy.column,
|
||||
)
|
||||
: null;
|
||||
const orderByColumn =
|
||||
matchedOrderByColumn?.clickhouseTableName === "traces"
|
||||
? matchedOrderByColumn
|
||||
: null;
|
||||
|
||||
const needsTracesCTE = traceFilterState.length > 0 || !!orderByColumn;
|
||||
|
||||
@@ -1806,6 +1808,10 @@ export const getAggregatedScoresForPrompts = async (
|
||||
projectId: string,
|
||||
promptIds: string[],
|
||||
fetchScoreRelation: "observation" | "trace",
|
||||
{
|
||||
fromTimestamp,
|
||||
toTimestamp,
|
||||
}: { fromTimestamp?: Date; toTimestamp?: Date } = {},
|
||||
) => {
|
||||
const query = `
|
||||
SELECT
|
||||
@@ -1827,6 +1833,8 @@ export const getAggregatedScoresForPrompts = async (
|
||||
AND s.project_id = {projectId: String}
|
||||
AND o.prompt_id IN ({promptIds: Array(String)})
|
||||
AND o.type = 'GENERATION'
|
||||
${fromTimestamp ? "AND o.start_time >= {fromTimestamp: DateTime64(3)}" : ""}
|
||||
${toTimestamp ? "AND o.start_time <= {toTimestamp: DateTime64(3)}" : ""}
|
||||
AND s.name IS NOT NULL
|
||||
${fetchScoreRelation === "trace" ? "AND s.observation_id IS NULL" : ""}
|
||||
AND s.data_type IN ({dataTypes: Array(String)})
|
||||
@@ -1844,6 +1852,12 @@ export const getAggregatedScoresForPrompts = async (
|
||||
projectId,
|
||||
promptIds,
|
||||
dataTypes: LISTABLE_SCORE_TYPES,
|
||||
...(fromTimestamp
|
||||
? { fromTimestamp: convertDateToClickhouseDateTime(fromTimestamp) }
|
||||
: {}),
|
||||
...(toTimestamp
|
||||
? { toTimestamp: convertDateToClickhouseDateTime(toTimestamp) }
|
||||
: {}),
|
||||
},
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
@@ -2267,6 +2281,7 @@ export const getScoreCountsByProjectAndDay = async ({
|
||||
endDate: convertDateToClickhouseDateTime(endDate),
|
||||
dataTypes: LISTABLE_SCORE_TYPES,
|
||||
},
|
||||
clickhouseConfigs: { request_timeout: 120_000 },
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "score",
|
||||
|
||||
@@ -1641,6 +1641,7 @@ export const getTraceCountsByProjectAndDay = async ({
|
||||
startDate: convertDateToClickhouseDateTime(startDate),
|
||||
endDate: convertDateToClickhouseDateTime(endDate),
|
||||
},
|
||||
clickhouseConfigs: { request_timeout: 120_000 },
|
||||
tags: {
|
||||
feature: "tracing",
|
||||
type: "trace",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "../../../db";
|
||||
import { TableViewPresetTableName } from "../../../domain/table-view-presets";
|
||||
import type {
|
||||
DefaultViewAssignments,
|
||||
DefaultViewScope,
|
||||
@@ -26,26 +27,64 @@ interface ClearDefaultParams {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const getReadCompatibleViewNames = (viewName: string) =>
|
||||
viewName === TableViewPresetTableName.ObservationsEvents
|
||||
? [
|
||||
TableViewPresetTableName.ObservationsEvents,
|
||||
TableViewPresetTableName.Observations,
|
||||
]
|
||||
: [viewName];
|
||||
|
||||
const getCanonicalViewName = (viewName: string) =>
|
||||
viewName === TableViewPresetTableName.ObservationsEvents
|
||||
? TableViewPresetTableName.ObservationsEvents
|
||||
: viewName;
|
||||
|
||||
const pickPreferredDefaultViewId = (
|
||||
defaults: {
|
||||
viewId: string;
|
||||
viewName: string;
|
||||
userId: string | null;
|
||||
}[],
|
||||
preferredViewNames: string[],
|
||||
userId: string | null,
|
||||
) =>
|
||||
preferredViewNames
|
||||
.map((viewName) =>
|
||||
defaults.find((defaultView) => {
|
||||
return (
|
||||
defaultView.viewName === viewName && defaultView.userId === userId
|
||||
);
|
||||
}),
|
||||
)
|
||||
.find(Boolean)?.viewId ?? null;
|
||||
|
||||
export class DefaultViewService {
|
||||
public static async getDefaultAssignments({
|
||||
projectId,
|
||||
viewName,
|
||||
userId,
|
||||
}: GetResolvedDefaultParams): Promise<DefaultViewAssignments> {
|
||||
const compatibleViewNames = getReadCompatibleViewNames(viewName);
|
||||
const defaults = await prisma.defaultView.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
viewName,
|
||||
viewName: {
|
||||
in: compatibleViewNames,
|
||||
},
|
||||
OR: userId ? [{ userId }, { userId: null }] : [{ userId: null }],
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
userDefaultViewId: userId
|
||||
? (defaults.find((d) => d.userId === userId)?.viewId ?? null)
|
||||
? pickPreferredDefaultViewId(defaults, compatibleViewNames, userId)
|
||||
: null,
|
||||
projectDefaultViewId:
|
||||
defaults.find((d) => d.userId === null)?.viewId ?? null,
|
||||
projectDefaultViewId: pickPreferredDefaultViewId(
|
||||
defaults,
|
||||
compatibleViewNames,
|
||||
null,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +126,7 @@ export class DefaultViewService {
|
||||
userId,
|
||||
}: SetAsDefaultParams): Promise<void> {
|
||||
const userIdToUse = scope === "user" ? userId : null;
|
||||
const canonicalViewName = getCanonicalViewName(viewName);
|
||||
|
||||
if (scope === "user" && !userId) {
|
||||
throw new Error("userId is required for user-level defaults");
|
||||
@@ -99,7 +139,7 @@ export class DefaultViewService {
|
||||
const existing = await tx.defaultView.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
viewName,
|
||||
viewName: canonicalViewName,
|
||||
userId: userIdToUse,
|
||||
},
|
||||
});
|
||||
@@ -107,14 +147,14 @@ export class DefaultViewService {
|
||||
if (existing) {
|
||||
await tx.defaultView.update({
|
||||
where: { id: existing.id },
|
||||
data: { viewId },
|
||||
data: { viewId, viewName: canonicalViewName },
|
||||
});
|
||||
} else {
|
||||
await tx.defaultView.create({
|
||||
data: {
|
||||
projectId,
|
||||
userId: userIdToUse,
|
||||
viewName,
|
||||
viewName: canonicalViewName,
|
||||
viewId,
|
||||
},
|
||||
});
|
||||
@@ -131,6 +171,7 @@ export class DefaultViewService {
|
||||
userId,
|
||||
}: ClearDefaultParams): Promise<void> {
|
||||
const userIdToUse = scope === "user" ? userId : null;
|
||||
const canonicalViewName = getCanonicalViewName(viewName);
|
||||
|
||||
if (scope === "user" && !userId) {
|
||||
throw new Error("userId is required for clearing user-level defaults");
|
||||
@@ -139,7 +180,7 @@ export class DefaultViewService {
|
||||
await prisma.defaultView.deleteMany({
|
||||
where: {
|
||||
projectId,
|
||||
viewName,
|
||||
viewName: canonicalViewName,
|
||||
userId: userIdToUse,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -22,6 +22,10 @@ import { backOff } from "exponential-backoff";
|
||||
import { ServiceUnavailableError } from "../../errors";
|
||||
import { BufferedStreamUploader } from "./BufferedStreamUploader";
|
||||
import { S3ChunkedUploadStrategy } from "./S3ChunkedUploadStrategy";
|
||||
import * as objectstorage from "oci-objectstorage";
|
||||
import * as common from "oci-common";
|
||||
import { UploadManager as OciUploadManager } from "oci-objectstorage";
|
||||
import { URL } from "node:url";
|
||||
|
||||
export interface S3SseConfig {
|
||||
serverSideEncryption?: string;
|
||||
@@ -127,6 +131,7 @@ export class StorageServiceFactory {
|
||||
* @param params.region - Region in which the bucket resides
|
||||
* @param params.forcePathStyle - Add bucket name into the path instead of the domain name. Mainly used for MinIO.
|
||||
* @param params.useAzureBlob - Use Azure Blob Storage instead of S3
|
||||
* @param params.useOCIObjectStorage - Use OCI Object Storage instead of S3
|
||||
* @param params.useGoogleCloudStorage - Use Google Cloud Storage instead of S3
|
||||
* @param params.googleCloudCredentials - Google Cloud Storage credentials JSON string or path to credentials file
|
||||
* @param params.awsSse - Server-side encryption method (e.g., "aws:kms")
|
||||
@@ -142,6 +147,7 @@ export class StorageServiceFactory {
|
||||
forcePathStyle: boolean;
|
||||
useAzureBlob?: boolean;
|
||||
useGoogleCloudStorage?: boolean;
|
||||
useOCIObjectStorage?: boolean;
|
||||
googleCloudCredentials?: string;
|
||||
awsSse: string | undefined;
|
||||
awsSseKmsKeyId: string | undefined;
|
||||
@@ -167,6 +173,13 @@ export class StorageServiceFactory {
|
||||
};
|
||||
return new GoogleCloudStorageService(googleParams);
|
||||
}
|
||||
if (
|
||||
params.useOCIObjectStorage !== undefined
|
||||
? params.useOCIObjectStorage
|
||||
: env.LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE === "true"
|
||||
) {
|
||||
return new OCIObjectStorageService(params);
|
||||
}
|
||||
return new S3StorageService(params);
|
||||
}
|
||||
}
|
||||
@@ -496,6 +509,10 @@ class S3StorageService implements StorageService {
|
||||
endpoint: params.endpoint,
|
||||
region: params.region,
|
||||
forcePathStyle: params.forcePathStyle,
|
||||
// Restore pre-v3.729 default so CompleteMultipartUpload doesn't send a
|
||||
// composite CRC32 header, which GCS's S3-compat layer rejects with 412.
|
||||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||
responseChecksumValidation: "WHEN_REQUIRED",
|
||||
requestHandler: {
|
||||
httpsAgent: {
|
||||
maxSockets: env.LANGFUSE_S3_CONCURRENT_WRITES,
|
||||
@@ -512,6 +529,8 @@ class S3StorageService implements StorageService {
|
||||
endpoint: params.externalEndpoint,
|
||||
region: params.region,
|
||||
forcePathStyle: params.forcePathStyle,
|
||||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||
responseChecksumValidation: "WHEN_REQUIRED",
|
||||
requestHandler: {
|
||||
httpsAgent: {
|
||||
maxSockets: env.LANGFUSE_S3_CONCURRENT_WRITES,
|
||||
@@ -1009,3 +1028,492 @@ class GoogleCloudStorageService implements StorageService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OCIObjectStorageService implements StorageService {
|
||||
private client?: objectstorage.ObjectStorageClient;
|
||||
private clientInit: Promise<void>;
|
||||
private bucketName: string;
|
||||
private externalEndpoint?: string;
|
||||
private namespaceName: string = "";
|
||||
|
||||
constructor(params: {
|
||||
bucketName: string;
|
||||
endpoint: string | undefined;
|
||||
externalEndpoint?: string | undefined;
|
||||
region: string | undefined;
|
||||
}) {
|
||||
this.bucketName = params.bucketName;
|
||||
this.externalEndpoint = params.externalEndpoint;
|
||||
this.clientInit = this.initClient(params);
|
||||
}
|
||||
|
||||
private async initClient(params: { endpoint?: string; region?: string }) {
|
||||
let provider: common.AuthenticationDetailsProvider;
|
||||
switch (env.LANGFUSE_OCI_AUTH_TYPE) {
|
||||
case "workload_identity": {
|
||||
provider =
|
||||
new common.OkeWorkloadIdentityAuthenticationDetailsProvider.OkeWorkloadIdentityAuthenticationDetailsProviderBuilder().build();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "instance_principal": {
|
||||
provider =
|
||||
await new common.InstancePrincipalsAuthenticationDetailsProviderBuilder().build();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "resource_principal": {
|
||||
provider =
|
||||
common.ResourcePrincipalAuthenticationDetailsProvider.builder();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "oci_profile": {
|
||||
provider = new common.ConfigFileAuthenticationDetailsProvider(
|
||||
env.LANGFUSE_OCI_CONFIG_FILE,
|
||||
env.LANGFUSE_OCI_CONFIG_PROFILE,
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "session_token": {
|
||||
provider = new common.SessionAuthDetailProvider(
|
||||
env.LANGFUSE_OCI_CONFIG_FILE,
|
||||
env.LANGFUSE_OCI_CONFIG_PROFILE,
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
"OCI auth not configured: set LANGFUSE_OCI_AUTH_TYPE to " +
|
||||
"'workload_identity' | 'instance_principal' | 'resource_principal' | 'oci_profile' | 'session_token'",
|
||||
);
|
||||
}
|
||||
|
||||
this.client = new objectstorage.ObjectStorageClient({
|
||||
authenticationDetailsProvider: provider,
|
||||
});
|
||||
|
||||
const regionId = params.region?.trim();
|
||||
if (regionId) this.client.region = common.Region.fromRegionId(regionId);
|
||||
const endpoint = params.endpoint?.trim();
|
||||
if (endpoint) this.client.endpoint = endpoint;
|
||||
}
|
||||
|
||||
private async ensureClient() {
|
||||
await this.clientInit;
|
||||
if (!this.client)
|
||||
throw new Error("OCI ObjectStorage client failed to initialize");
|
||||
return this.client;
|
||||
}
|
||||
|
||||
private async ensureNamespace(): Promise<string> {
|
||||
if (this.namespaceName) return this.namespaceName;
|
||||
const client = await this.ensureClient();
|
||||
const nsResp = await client.getNamespace({});
|
||||
this.namespaceName = nsResp.value ?? "";
|
||||
return this.namespaceName;
|
||||
}
|
||||
|
||||
private async getClientAndNamespace(): Promise<{
|
||||
client: objectstorage.ObjectStorageClient;
|
||||
namespaceName: string;
|
||||
}> {
|
||||
const client = await this.ensureClient();
|
||||
const namespaceName = await this.ensureNamespace(); // uses the same client init + cached namespace
|
||||
return { client, namespaceName };
|
||||
}
|
||||
|
||||
private async streamToString(
|
||||
readable: any, // could be many shapes, so use `any`
|
||||
): Promise<string> {
|
||||
if (!readable) return "";
|
||||
|
||||
// Helper: convert many chunk shapes to Buffer
|
||||
const toBuffer = (chunk: any): Buffer => {
|
||||
if (Buffer.isBuffer(chunk)) return chunk;
|
||||
if (typeof chunk === "string") return Buffer.from(chunk, "utf8");
|
||||
if (chunk instanceof ArrayBuffer) return Buffer.from(chunk);
|
||||
// TypedArray / DataView
|
||||
if (ArrayBuffer.isView(chunk)) {
|
||||
return Buffer.from(
|
||||
(chunk as Uint8Array).buffer,
|
||||
(chunk as any).byteOffset ?? 0,
|
||||
(chunk as any).byteLength ?? undefined,
|
||||
);
|
||||
}
|
||||
// Fallback: try Buffer.from (may throw)
|
||||
return Buffer.from(chunk);
|
||||
};
|
||||
|
||||
// 1) Node.js Readable (EventEmitter style)
|
||||
if (
|
||||
typeof readable.on === "function" &&
|
||||
typeof readable.read !== "undefined"
|
||||
) {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
readable.on("data", (chunk: any) => {
|
||||
try {
|
||||
chunks.push(toBuffer(chunk));
|
||||
} catch (_err) {
|
||||
// if conversion fails, push as Buffer of stringified chunk
|
||||
chunks.push(Buffer.from(String(chunk)));
|
||||
}
|
||||
});
|
||||
readable.on("error", (err: any) => reject(err));
|
||||
readable.on("end", () => {
|
||||
resolve(Buffer.concat(chunks).toString("utf8"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 2) WHATWG ReadableStream (browser / some fetch-like APIs)
|
||||
if (typeof readable.getReader === "function") {
|
||||
const reader = readable.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(toBuffer(value));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
} finally {
|
||||
// safe to close reader if available
|
||||
try {
|
||||
if (reader.releaseLock) reader.releaseLock();
|
||||
} catch (_err) {
|
||||
// intentionally ignore releaseLock errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Buffer / Uint8Array / ArrayBuffer direct
|
||||
if (Buffer.isBuffer(readable)) return readable.toString("utf8");
|
||||
if (readable instanceof Uint8Array)
|
||||
return Buffer.from(readable).toString("utf8");
|
||||
if (readable instanceof ArrayBuffer)
|
||||
return Buffer.from(readable).toString("utf8");
|
||||
|
||||
// 4) Blob (browser)
|
||||
if (typeof Blob !== "undefined" && readable instanceof Blob) {
|
||||
const ab = await readable.arrayBuffer();
|
||||
return Buffer.from(ab).toString("utf8");
|
||||
}
|
||||
|
||||
// 5) Async iterable (some stream implementations)
|
||||
if (typeof readable[Symbol.asyncIterator] === "function") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of readable) {
|
||||
chunks.push(toBuffer(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
// 6) Synchronous iterable
|
||||
if (typeof readable[Symbol.iterator] === "function") {
|
||||
const chunks: Buffer[] = [];
|
||||
for (const chunk of readable) {
|
||||
chunks.push(toBuffer(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
// 7) Fallback: try string conversion
|
||||
try {
|
||||
return String(readable);
|
||||
} catch (_err) {
|
||||
// If all else fails, throw a helpful error
|
||||
throw new TypeError("Unsupported body type passed to streamToString");
|
||||
}
|
||||
}
|
||||
public async uploadFile({
|
||||
fileName,
|
||||
fileType,
|
||||
data,
|
||||
partSize,
|
||||
queueSize,
|
||||
}: UploadFile): Promise<void> {
|
||||
try {
|
||||
const { client, namespaceName } = await this.getClientAndNamespace();
|
||||
const uploadManager = new OciUploadManager(client, {
|
||||
partSize: partSize ?? 20 * 1024 * 1024,
|
||||
maxConcurrentUploads: queueSize ?? 5,
|
||||
});
|
||||
|
||||
// UploadManager in the OCI SDK expects content shaped as one of:
|
||||
// { blob }, { filePath }, or { stream }.
|
||||
// To work reliably in Node, always provide { stream }.
|
||||
const stream =
|
||||
typeof data === "string"
|
||||
? Readable.from([data])
|
||||
: data instanceof Readable
|
||||
? data
|
||||
: Buffer.isBuffer(data as any)
|
||||
? Readable.from([data as any])
|
||||
: Readable.from([String(data)]);
|
||||
|
||||
const contentLength =
|
||||
typeof data === "string"
|
||||
? Buffer.byteLength(data)
|
||||
: Buffer.isBuffer(data as any)
|
||||
? (data as any).byteLength
|
||||
: undefined;
|
||||
|
||||
await uploadManager.upload({
|
||||
requestDetails: {
|
||||
namespaceName,
|
||||
bucketName: this.bucketName,
|
||||
objectName: fileName,
|
||||
contentType: fileType,
|
||||
...(contentLength ? { contentLength } : {}),
|
||||
},
|
||||
content: { stream },
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to upload file to OCI Object Storage ${fileName}`,
|
||||
err,
|
||||
);
|
||||
handleStorageError(err, "upload file to OCI Object Storage ");
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadFileBuffered({
|
||||
fileName,
|
||||
fileType,
|
||||
data,
|
||||
partSizeBytes,
|
||||
}: UploadFileBuffered): Promise<void> {
|
||||
await this.uploadFile({
|
||||
fileName,
|
||||
fileType,
|
||||
data,
|
||||
partSize: partSizeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
public async uploadWithSignedUrl({
|
||||
fileName,
|
||||
fileType,
|
||||
data,
|
||||
expiresInSeconds,
|
||||
partSize,
|
||||
queueSize,
|
||||
}: UploadWithSignedUrl): Promise<{ signedUrl: string }> {
|
||||
try {
|
||||
await this.uploadFile({ fileName, data, fileType, partSize, queueSize });
|
||||
|
||||
const signedUrl = await this.getSignedUrl(fileName, expiresInSeconds);
|
||||
|
||||
return { signedUrl };
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to upload file to OCI Object Storage ${fileName}`,
|
||||
err,
|
||||
);
|
||||
handleStorageError(
|
||||
err,
|
||||
"upload file to OCI Object Storage or generate signed URL",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadJson(path: string, body: Record<string, unknown>[]) {
|
||||
try {
|
||||
const { client, namespaceName } = await this.getClientAndNamespace();
|
||||
const jsonString = JSON.stringify(body);
|
||||
const req: objectstorage.requests.PutObjectRequest = {
|
||||
namespaceName,
|
||||
bucketName: this.bucketName,
|
||||
objectName: path,
|
||||
contentLength: Buffer.byteLength(jsonString),
|
||||
putObjectBody: Readable.from([jsonString]),
|
||||
contentType: "application/json",
|
||||
};
|
||||
await client.putObject(req);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to upload JSON to OCI Object Storage ${path}`, err);
|
||||
handleStorageError(err, "upload JSON to OCI Object Storage ");
|
||||
}
|
||||
}
|
||||
|
||||
public async download(path: string): Promise<string> {
|
||||
try {
|
||||
const { client, namespaceName } = await this.getClientAndNamespace();
|
||||
const req: objectstorage.requests.GetObjectRequest = {
|
||||
namespaceName,
|
||||
bucketName: this.bucketName,
|
||||
objectName: path,
|
||||
};
|
||||
const response = await client.getObject(req);
|
||||
const bodyStream = (response as any).value as
|
||||
| NodeJS.ReadableStream
|
||||
| undefined;
|
||||
return await this.streamToString(bodyStream);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to download file from OCI Object Storage ${path}`,
|
||||
err,
|
||||
);
|
||||
handleStorageError(err, "download file from OCI Object Storage ");
|
||||
}
|
||||
}
|
||||
|
||||
public async listFiles(
|
||||
prefix: string,
|
||||
): Promise<{ file: string; createdAt: Date }[]> {
|
||||
try {
|
||||
const { client, namespaceName } = await this.getClientAndNamespace();
|
||||
const req: objectstorage.requests.ListObjectsRequest = {
|
||||
namespaceName,
|
||||
bucketName: this.bucketName,
|
||||
prefix,
|
||||
};
|
||||
const resp = await client.listObjects(req);
|
||||
const objects = ((resp as any).listObjects?.objects ?? []) as Array<{
|
||||
name?: string;
|
||||
timeCreated?: Date | string;
|
||||
}>;
|
||||
return (
|
||||
objects.flatMap((obj) =>
|
||||
obj.name
|
||||
? [
|
||||
{
|
||||
file: obj.name,
|
||||
createdAt: obj.timeCreated
|
||||
? new Date(obj.timeCreated as any)
|
||||
: new Date(),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
) ?? []
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to list files from OCI Object Storage ${prefix}`,
|
||||
err,
|
||||
);
|
||||
handleStorageError(err, "list files from OCI Object Storage ");
|
||||
}
|
||||
}
|
||||
|
||||
public async getSignedUrl(
|
||||
fileName: string,
|
||||
ttlSeconds: number,
|
||||
asAttachment: boolean = true,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const { client, namespaceName } = await this.getClientAndNamespace();
|
||||
const expiresOn = new Date(Date.now() + ttlSeconds * 1000);
|
||||
const req: objectstorage.requests.CreatePreauthenticatedRequestRequest = {
|
||||
namespaceName,
|
||||
bucketName: this.bucketName,
|
||||
createPreauthenticatedRequestDetails: {
|
||||
name: `read-${fileName}-${Date.now()}`,
|
||||
accessType: "ObjectRead" as any,
|
||||
objectName: fileName,
|
||||
timeExpires: expiresOn as any,
|
||||
} as any,
|
||||
};
|
||||
const resp = await client.createPreauthenticatedRequest(req);
|
||||
const accessUri = (resp.preauthenticatedRequest as any)
|
||||
.accessUri as string;
|
||||
const base = this.externalEndpoint ?? client.endpoint;
|
||||
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
"Cannot build PAR URL: no externalEndpoint configured and client.endpoint is empty",
|
||||
);
|
||||
}
|
||||
const baseUrl = new URL(base);
|
||||
const parUrl = new URL(accessUri, baseUrl);
|
||||
if (asAttachment) {
|
||||
parUrl.searchParams.set("download", "1");
|
||||
}
|
||||
const url = parUrl.toString();
|
||||
return url;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to generate presigned URL (PAR) for OCI Object Storage ${fileName}`,
|
||||
err,
|
||||
);
|
||||
handleStorageError(err, "generate signed URL for OCI Object Storage ");
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteFiles(paths: string[]): Promise<void> {
|
||||
try {
|
||||
const { client, namespaceName } = await this.getClientAndNamespace();
|
||||
for (const p of paths) {
|
||||
const req: objectstorage.requests.DeleteObjectRequest = {
|
||||
namespaceName,
|
||||
bucketName: this.bucketName,
|
||||
objectName: p,
|
||||
} as any;
|
||||
await client.deleteObject(req as any);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to delete files from OCI Object Storage `, {
|
||||
error: err,
|
||||
files: paths,
|
||||
});
|
||||
handleStorageError(err, "delete files from OCI Object Storage ");
|
||||
}
|
||||
}
|
||||
|
||||
public async getSignedUploadUrl(params: {
|
||||
path: string;
|
||||
ttlSeconds: number;
|
||||
sha256Hash: string;
|
||||
contentType: string;
|
||||
contentLength: number;
|
||||
}): Promise<string> {
|
||||
const { path, ttlSeconds } = params;
|
||||
try {
|
||||
const { client, namespaceName } = await this.getClientAndNamespace();
|
||||
const expiresOn = new Date(Date.now() + ttlSeconds * 1000);
|
||||
const req: objectstorage.requests.CreatePreauthenticatedRequestRequest = {
|
||||
namespaceName,
|
||||
bucketName: this.bucketName,
|
||||
createPreauthenticatedRequestDetails: {
|
||||
name: `write-${path}-${Date.now()}`,
|
||||
accessType: "ObjectWrite" as any,
|
||||
objectName: path,
|
||||
timeExpires: expiresOn as any,
|
||||
} as any,
|
||||
};
|
||||
const resp = await client.createPreauthenticatedRequest(req);
|
||||
const accessUri = (resp.preauthenticatedRequest as any)
|
||||
.accessUri as string;
|
||||
const base = this.externalEndpoint ?? client.endpoint;
|
||||
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
"Cannot build PAR URL: no externalEndpoint configured and client.endpoint is empty",
|
||||
);
|
||||
}
|
||||
const baseUrl = new URL(base);
|
||||
let url = new URL(accessUri, baseUrl).toString();
|
||||
return url;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to generate presigned upload URL (PAR) for OCI Object Storage ${path}`,
|
||||
err,
|
||||
);
|
||||
handleStorageError(
|
||||
err,
|
||||
"generate presigned upload URL for OCI Object Storage ",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../../db";
|
||||
import {
|
||||
TableViewPresetTableName,
|
||||
type TableViewPresetDomain,
|
||||
} from "../../../domain/table-view-presets";
|
||||
import { LangfuseNotFoundError } from "../../../errors";
|
||||
import { LangfuseConflictError, LangfuseNotFoundError } from "../../../errors";
|
||||
import {
|
||||
TableViewPresetsNamesCreatorList,
|
||||
TableViewPresetsNamesCreatorListSchema,
|
||||
@@ -12,11 +13,44 @@ import {
|
||||
type UpdateTableViewPresetsInput,
|
||||
} from "./types";
|
||||
|
||||
const TABLE_NAME_TO_URL_MAP = <Record<TableViewPresetTableName, string>>{
|
||||
[TableViewPresetTableName.Traces]: "traces",
|
||||
[TableViewPresetTableName.Observations]: "observations",
|
||||
[TableViewPresetTableName.Scores]: "scores",
|
||||
[TableViewPresetTableName.Sessions]: "sessions",
|
||||
const TABLE_NAME_TO_URL_MAP: Partial<Record<TableViewPresetTableName, string>> =
|
||||
{
|
||||
[TableViewPresetTableName.Traces]: "traces",
|
||||
[TableViewPresetTableName.Observations]: "observations",
|
||||
[TableViewPresetTableName.ObservationsEvents]: "traces",
|
||||
[TableViewPresetTableName.Scores]: "scores",
|
||||
[TableViewPresetTableName.Sessions]: "sessions",
|
||||
[TableViewPresetTableName.Datasets]: "datasets",
|
||||
[TableViewPresetTableName.Experiments]: "experiments",
|
||||
[TableViewPresetTableName.ExperimentItems]: "experiments/results",
|
||||
};
|
||||
|
||||
// The v4 table was mistakenly released under the `observations` table name,
|
||||
// so we need to read legacy presets that belong to the events table under the `observations` name.
|
||||
// To avoid proliferating this compatibility logic, we only apply it when reading presets for the events table,
|
||||
// and we never allow it when writing (creating/updating) presets.
|
||||
const getReadCompatibleTableNames = (
|
||||
tableName: TableViewPresetTableName,
|
||||
): TableViewPresetTableName[] =>
|
||||
tableName === TableViewPresetTableName.ObservationsEvents
|
||||
? [
|
||||
TableViewPresetTableName.ObservationsEvents,
|
||||
TableViewPresetTableName.Observations,
|
||||
]
|
||||
: [tableName];
|
||||
|
||||
const TABLE_VIEW_PRESET_NAME_CONFLICT_MESSAGE =
|
||||
"Table view preset with this name already exists. Please choose a different name.";
|
||||
|
||||
const throwTableViewPresetConflictIfDuplicateName = (error: unknown): never => {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
) {
|
||||
throw new LangfuseConflictError(TABLE_VIEW_PRESET_NAME_CONFLICT_MESSAGE);
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
|
||||
export class TableViewService {
|
||||
@@ -46,11 +80,13 @@ export class TableViewService {
|
||||
input: UpdateTableViewPresetsInput,
|
||||
updatedBy: string,
|
||||
): Promise<TableViewPresetDomain> {
|
||||
const tableViewPresets = await prisma.tableViewPreset.findUnique({
|
||||
const tableViewPresets = await prisma.tableViewPreset.findFirst({
|
||||
where: {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
tableName: input.tableName,
|
||||
tableName: {
|
||||
in: getReadCompatibleTableNames(input.tableName),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,20 +96,28 @@ export class TableViewService {
|
||||
);
|
||||
}
|
||||
|
||||
const updatedTableViewPresets = await prisma.tableViewPreset.update({
|
||||
where: {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
tableName: input.tableName,
|
||||
},
|
||||
data: {
|
||||
...input,
|
||||
orderBy: input.orderBy ?? undefined,
|
||||
updatedBy,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const updatedTableViewPresets = await prisma.tableViewPreset.update({
|
||||
where: {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
data: {
|
||||
name: input.name,
|
||||
tableName: input.tableName,
|
||||
filters: input.filters,
|
||||
columnOrder: input.columnOrder,
|
||||
columnVisibility: input.columnVisibility,
|
||||
searchQuery: input.searchQuery,
|
||||
orderBy: input.orderBy ?? undefined,
|
||||
updatedBy,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedTableViewPresets as unknown as TableViewPresetDomain;
|
||||
return updatedTableViewPresets as unknown as TableViewPresetDomain;
|
||||
} catch (error) {
|
||||
return throwTableViewPresetConflictIfDuplicateName(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,11 +127,13 @@ export class TableViewService {
|
||||
input: UpdateTableViewPresetsNameInput,
|
||||
updatedBy: string,
|
||||
): Promise<TableViewPresetDomain> {
|
||||
const tableViewPresets = await prisma.tableViewPreset.findUnique({
|
||||
const tableViewPresets = await prisma.tableViewPreset.findFirst({
|
||||
where: {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
tableName: input.tableName,
|
||||
tableName: {
|
||||
in: getReadCompatibleTableNames(input.tableName),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,19 +143,23 @@ export class TableViewService {
|
||||
);
|
||||
}
|
||||
|
||||
const updatedTableViewPresets = await prisma.tableViewPreset.update({
|
||||
where: {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
tableName: input.tableName,
|
||||
},
|
||||
data: {
|
||||
name: input.name,
|
||||
updatedBy,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const updatedTableViewPresets = await prisma.tableViewPreset.update({
|
||||
where: {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
data: {
|
||||
name: input.name,
|
||||
tableName: input.tableName,
|
||||
updatedBy,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedTableViewPresets as unknown as TableViewPresetDomain;
|
||||
return updatedTableViewPresets as unknown as TableViewPresetDomain;
|
||||
} catch (error) {
|
||||
return throwTableViewPresetConflictIfDuplicateName(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,17 +181,20 @@ export class TableViewService {
|
||||
* Gets all table view presets for a table
|
||||
*/
|
||||
public static async getTableViewPresetsByTableName(
|
||||
tableName: string,
|
||||
tableName: TableViewPresetTableName,
|
||||
projectId: string,
|
||||
): Promise<TableViewPresetsNamesCreatorList> {
|
||||
const TableViewPresets = await prisma.tableViewPreset.findMany({
|
||||
const records = await prisma.tableViewPreset.findMany({
|
||||
where: {
|
||||
tableName,
|
||||
tableName: {
|
||||
in: getReadCompatibleTableNames(tableName),
|
||||
},
|
||||
projectId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
tableName: true,
|
||||
createdBy: true,
|
||||
createdByUser: {
|
||||
select: {
|
||||
@@ -157,7 +210,33 @@ export class TableViewService {
|
||||
},
|
||||
});
|
||||
|
||||
return TableViewPresetsNamesCreatorListSchema.parse(TableViewPresets);
|
||||
const presets = TableViewPresetsNamesCreatorListSchema.parse(records);
|
||||
|
||||
if (tableName === TableViewPresetTableName.ObservationsEvents) {
|
||||
// Deduplicate presets that have the same name,
|
||||
// preferring presets that belong to the canonical events table namespace
|
||||
// over presets that belong to the legacy observations namespace.
|
||||
const presetsByName = new Map<
|
||||
string,
|
||||
TableViewPresetsNamesCreatorList[number]
|
||||
>();
|
||||
|
||||
for (const preset of presets) {
|
||||
const existingPreset = presetsByName.get(preset.name);
|
||||
|
||||
if (
|
||||
!existingPreset ||
|
||||
(preset.tableName === TableViewPresetTableName.ObservationsEvents &&
|
||||
existingPreset.tableName === TableViewPresetTableName.Observations)
|
||||
) {
|
||||
presetsByName.set(preset.name, preset);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(presetsByName.values());
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,6 +272,9 @@ export class TableViewService {
|
||||
projectId: string,
|
||||
): Promise<string> {
|
||||
const page = TABLE_NAME_TO_URL_MAP[tableName];
|
||||
if (!page) {
|
||||
throw new Error(`Permalinks are not supported for table ${tableName}`);
|
||||
}
|
||||
return `${baseUrl}/project/${projectId}/${page}?viewId=${TableViewPresetsId}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import z from "zod";
|
||||
import { orderBy, singleFilter } from "../../..";
|
||||
import { orderBy, singleFilter, TableViewPresetTableName } from "../../..";
|
||||
|
||||
export const CreateTableViewPresetsInput = z.object({
|
||||
projectId: z.string(),
|
||||
name: z.string().min(1, "View name is required"),
|
||||
tableName: z.string(),
|
||||
tableName: z.enum(TableViewPresetTableName),
|
||||
filters: z.array(singleFilter),
|
||||
columnOrder: z.array(z.string()),
|
||||
columnVisibility: z.record(z.string(), z.boolean()),
|
||||
@@ -19,7 +19,7 @@ export const UpdateTableViewPresetsInput = CreateTableViewPresetsInput.extend({
|
||||
export const UpdateTableViewPresetsNameInput = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
tableName: z.string(),
|
||||
tableName: z.enum(TableViewPresetTableName),
|
||||
projectId: z.string(),
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ export const TableViewPresetsNamesCreatorListSchema = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
tableName: z.enum(TableViewPresetTableName),
|
||||
createdBy: z.string().nullable(),
|
||||
createdByUser: z
|
||||
.object({
|
||||
|
||||
@@ -227,6 +227,22 @@ export async function notifyBlockedEvaluatorConfigs({
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
logger.warn(
|
||||
`[EVALUATOR BLOCK] Project ${projectId} not found. Skipping notifications.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const blockedConfigs = await prisma.jobConfiguration.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
@@ -254,6 +270,7 @@ export async function notifyBlockedEvaluatorConfigs({
|
||||
adminEmails.map((receiverEmail) =>
|
||||
sendEvaluatorBlockedEmail({
|
||||
env: emailEnv,
|
||||
projectName: project.name,
|
||||
evaluatorName: config.evalTemplate?.name ?? config.scoreName,
|
||||
blockReason,
|
||||
blockMessage,
|
||||
|
||||
+9
-5
@@ -16,6 +16,7 @@ import {
|
||||
import { EvaluatorBlockReason } from "@prisma/client";
|
||||
|
||||
type EvaluatorBlockedEmailTemplateProps = {
|
||||
projectName: string;
|
||||
evaluatorName: string;
|
||||
blockReason: EvaluatorBlockReason;
|
||||
blockMessage: string;
|
||||
@@ -104,6 +105,7 @@ const getResolutionSteps = (blockReason: EvaluatorBlockReason) => {
|
||||
};
|
||||
|
||||
export const EvaluatorBlockedEmailTemplate = ({
|
||||
projectName,
|
||||
evaluatorName,
|
||||
blockReason,
|
||||
blockMessage,
|
||||
@@ -114,8 +116,8 @@ export const EvaluatorBlockedEmailTemplate = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>
|
||||
LLM evaluator "{evaluatorName}" paused:{" "}
|
||||
{getReasonSummary(blockReason)}
|
||||
LLM evaluator "{evaluatorName}" in project "
|
||||
{projectName}" paused: {getReasonSummary(blockReason)}
|
||||
</Preview>
|
||||
<Tailwind>
|
||||
<Body className="bg-background my-auto mx-auto font-sans">
|
||||
@@ -135,8 +137,9 @@ export const EvaluatorBlockedEmailTemplate = ({
|
||||
⚠️ Evaluator Paused
|
||||
</Heading>
|
||||
<Text className="text-gray-700 text-sm leading-6">
|
||||
The LLM evaluator "{evaluatorName}" was automatically
|
||||
paused because {getReasonSummary(blockReason).toLowerCase()}.
|
||||
The LLM evaluator "{evaluatorName}" in project "
|
||||
{projectName}" was automatically paused because{" "}
|
||||
{getReasonSummary(blockReason).toLowerCase()}.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
@@ -174,7 +177,8 @@ export const EvaluatorBlockedEmailTemplate = ({
|
||||
<Section>
|
||||
<Text className="text-[#666666] text-[12px] leading-[24px]">
|
||||
This notification was sent to {receiverEmail} regarding the
|
||||
paused evaluator "{evaluatorName}".
|
||||
paused evaluator "{evaluatorName}" in project "
|
||||
{projectName}".
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
+4
@@ -17,6 +17,7 @@ export type SendEvaluatorBlockedEmailParams = {
|
||||
string | undefined
|
||||
>
|
||||
>;
|
||||
projectName: string;
|
||||
evaluatorName: string;
|
||||
blockReason: EvaluatorBlockReason;
|
||||
blockMessage: string;
|
||||
@@ -26,6 +27,7 @@ export type SendEvaluatorBlockedEmailParams = {
|
||||
|
||||
export const sendEvaluatorBlockedEmail = async ({
|
||||
env,
|
||||
projectName,
|
||||
evaluatorName,
|
||||
blockReason,
|
||||
blockMessage,
|
||||
@@ -42,9 +44,11 @@ export const sendEvaluatorBlockedEmail = async ({
|
||||
try {
|
||||
const mailer = createTransport(parseConnectionUrl(env.SMTP_CONNECTION_URL));
|
||||
const safeEvaluatorName = sanitizeEmailSubject(evaluatorName);
|
||||
const safeProjectName = sanitizeEmailSubject(projectName);
|
||||
const subject = `⚠️ LLM evaluator "${safeEvaluatorName}" paused - action required`;
|
||||
const html = await render(
|
||||
EvaluatorBlockedEmailTemplate({
|
||||
projectName: safeProjectName,
|
||||
evaluatorName: safeEvaluatorName,
|
||||
blockReason,
|
||||
blockMessage,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { queryClickhouse } from "../repositories";
|
||||
import { sessionCols } from "../tableMappings/mapSessionTable";
|
||||
import { sessionsViewCols } from "../../tableDefinitions/sessionsView";
|
||||
import { findUiColumnMapping } from "../../tableDefinitions";
|
||||
import { parseClickhouseUTCDateTimeFormat } from "../repositories/clickhouse";
|
||||
|
||||
type SessionEventsBaseReturnType = {
|
||||
@@ -186,10 +187,8 @@ const getSessionsTableFromEventsGeneric = async <T>(
|
||||
|
||||
const requiresScoresJoin =
|
||||
sessionFilters.some((f) => f.clickhouseTable === "scores") ||
|
||||
sessionCols.find(
|
||||
(c) =>
|
||||
c.uiTableName === orderBy?.column || c.uiTableId === orderBy?.column,
|
||||
)?.clickhouseTableName === "scores";
|
||||
findUiColumnMapping(sessionCols, orderBy?.column)?.clickhouseTableName ===
|
||||
"scores";
|
||||
|
||||
// Build session_data CTE
|
||||
const sessionsBuilder = eventsSessionsAggregation({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { OrderByState } from "../../interfaces/orderBy";
|
||||
import { sessionCols } from "../tableMappings/mapSessionTable";
|
||||
import { FilterState } from "../../types";
|
||||
import { sessionsViewCols } from "../../tableDefinitions/sessionsView";
|
||||
import { findUiColumnMapping } from "../../tableDefinitions";
|
||||
import { convertDateToClickhouseDateTime } from "../clickhouse/client";
|
||||
import { measureAndReturn } from "../clickhouse/measureAndReturn";
|
||||
import { DateTimeFilter, FilterList, orderByToClickhouseSql } from "../queries";
|
||||
@@ -216,10 +217,8 @@ const getSessionsTableGeneric = async <T>(props: FetchSessionsTableProps) => {
|
||||
|
||||
const requiresScoresJoin =
|
||||
tracesFilter.find((f) => f.clickhouseTable === "scores") !== undefined ||
|
||||
sessionCols.find(
|
||||
(c) =>
|
||||
c.uiTableName === orderBy?.column || c.uiTableId === orderBy?.column,
|
||||
)?.clickhouseTableName === "scores";
|
||||
findUiColumnMapping(sessionCols, orderBy?.column)?.clickhouseTableName ===
|
||||
"scores";
|
||||
|
||||
const hasMetricsFilter =
|
||||
tracesFilter.find((f) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { OrderByState } from "../../interfaces/orderBy";
|
||||
import { tracesTableUiColumnDefinitions } from "../tableMappings";
|
||||
import { tracesTableCols } from "../../tableDefinitions/tracesTable";
|
||||
import { findUiColumnMapping } from "../../tableDefinitions";
|
||||
import { FilterState } from "../../types";
|
||||
import {
|
||||
StringFilter,
|
||||
@@ -269,18 +270,14 @@ async function getTracesTableGeneric(props: FetchTracesTableProps) {
|
||||
|
||||
const requiresScoresJoin =
|
||||
tracesFilter.find((f) => f.clickhouseTable === "scores") !== undefined ||
|
||||
tracesTableUiColumnDefinitions.find(
|
||||
(c) =>
|
||||
c.uiTableName === orderBy?.column || c.uiTableId === orderBy?.column,
|
||||
)?.clickhouseTableName === "scores";
|
||||
findUiColumnMapping(tracesTableUiColumnDefinitions, orderBy?.column)
|
||||
?.clickhouseTableName === "scores";
|
||||
|
||||
const requiresObservationsJoin =
|
||||
tracesFilter.find((f) => f.clickhouseTable === "observations") !==
|
||||
undefined ||
|
||||
tracesTableUiColumnDefinitions.find(
|
||||
(c) =>
|
||||
c.uiTableName === orderBy?.column || c.uiTableId === orderBy?.column,
|
||||
)?.clickhouseTableName === "observations";
|
||||
findUiColumnMapping(tracesTableUiColumnDefinitions, orderBy?.column)
|
||||
?.clickhouseTableName === "observations";
|
||||
|
||||
const tracesFilterRes = tracesFilter.apply();
|
||||
const scoresFilterRes = scoresFilter.apply();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { UiColumnMappings } from "../../tableDefinitions";
|
||||
import {
|
||||
matchesUiColumnMapping,
|
||||
UiColumnMappings,
|
||||
} from "../../tableDefinitions";
|
||||
import { DatasetRunItemDomain } from "../../domain/dataset-run-items";
|
||||
|
||||
export const datasetRunItemsTableUiColumnDefinitions: UiColumnMappings = [
|
||||
@@ -52,9 +55,7 @@ export const mapDatasetRunItemFilterColumn = (
|
||||
): unknown => {
|
||||
const columnDef = datasetRunItemsTableUiColumnDefinitions.find(
|
||||
(col) =>
|
||||
col.uiTableId === column ||
|
||||
col.uiTableName === column ||
|
||||
col.clickhouseSelect === column,
|
||||
matchesUiColumnMapping(col, column) || col.clickhouseSelect === column,
|
||||
);
|
||||
if (!columnDef) {
|
||||
throw new Error(`Unhandled column for dataset run items filter: ${column}`);
|
||||
|
||||
@@ -259,6 +259,12 @@ export const eventsTableNativeUiColumnDefinitions: UiColumnMappings = [
|
||||
clickhouseTableName: "events_proto",
|
||||
clickhouseSelect: 'e."experiment_name"',
|
||||
},
|
||||
{
|
||||
uiTableName: "Is Experiment Item Root Span",
|
||||
uiTableId: "isExperimentItemRootSpan",
|
||||
clickhouseTableName: "events_proto",
|
||||
clickhouseSelect: "e.experiment_item_root_span_id = e.span_id",
|
||||
},
|
||||
{
|
||||
uiTableName: "Available Tools",
|
||||
uiTableId: "toolDefinitions",
|
||||
|
||||
@@ -103,7 +103,14 @@ export const dashboardColumnDefinitions: UiColumnMappings = [
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: "mapKeys(tool_definitions)",
|
||||
uiTableId: "toolNames",
|
||||
uiTableName: "Tool Names",
|
||||
uiTableName: "Tool Names (Available)",
|
||||
aliases: ["Tool Names"],
|
||||
},
|
||||
{
|
||||
clickhouseTableName: "observations",
|
||||
clickhouseSelect: "tool_call_names",
|
||||
uiTableId: "calledToolNames",
|
||||
uiTableName: "Tool Names (Called)",
|
||||
},
|
||||
{
|
||||
clickhouseTableName: "traces",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user