Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4a13377e35 | ||
|
|
30af822ac9 | ||
|
|
c2c0b661e7 | ||
|
|
2e94ebfe4b | ||
|
|
b8544b3423 | ||
|
|
24cc309fb8 | ||
|
|
1ca70d7033 | ||
|
|
ba980c302e | ||
|
|
ea197e4287 | ||
|
|
0b20e4d366 | ||
|
|
31a1a34616 | ||
|
|
3c3d4bf129 | ||
|
|
07cae52cc7 | ||
|
|
a81edec0be | ||
|
|
497179934d | ||
|
|
ad9dfc41a2 | ||
|
|
9cc69f4c67 | ||
|
|
557f284cd1 | ||
|
|
6702c7b50f | ||
|
|
25d99aa371 | ||
|
|
e4d5f914cc | ||
|
|
dcb5dbf528 | ||
|
|
d003a9c3f4 | ||
|
|
d2d56f0337 | ||
|
|
6c0cf07a5a |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,42 @@
|
||||
---
|
||||
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.
|
||||
- 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 })),
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
# Dev container Dockerfile
|
||||
FROM --platform=${BUILDPLATFORM} golang:1.24 AS migrate-builder
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ENV CGO_ENABLED=0 \
|
||||
GOBIN=/out \
|
||||
GOOS=${TARGETOS} \
|
||||
GOARCH=${TARGETARCH}
|
||||
# Build only the ClickHouse migrate CLI used in this repo.
|
||||
RUN /usr/local/go/bin/go install -trimpath -tags 'clickhouse' -ldflags='-s -w' \
|
||||
github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
|
||||
|
||||
FROM mcr.microsoft.com/devcontainers/universal:2
|
||||
|
||||
# Install golang-migrate for database migrations
|
||||
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.linux-amd64.tar.gz | tar xvz && \
|
||||
chmod +x migrate && \
|
||||
mv migrate /usr/local/bin/migrate
|
||||
COPY --from=migrate-builder /out/migrate /usr/local/bin/migrate
|
||||
|
||||
# Activate the repo's pinned pnpm via Corepack
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
@@ -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.
|
||||
#####################################################################
|
||||
@@ -145,9 +145,15 @@ LANGFUSE_AI_FEATURES_SECRET_KEY="sk-lf-1234567890"
|
||||
LANGFUSE_AI_FEATURES_HOST="http://localhost:3000"
|
||||
LANGFUSE_AI_FEATURES_PROJECT_ID=7a88fb47-b4e2-43b8-a06c-a5ce950dc53a
|
||||
|
||||
# Self-hosted only: allow internal LLM proxy hosts/IPs for LLM connection base URLs.
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST=localhost
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS=127.0.0.1,::1
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS=127.0.0.0/8
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
@@ -299,6 +299,10 @@ LANGFUSE_ENABLE_BLOB_STORAGE_FILE_LOG=true
|
||||
|
||||
# Admin API
|
||||
# ADMIN_API_KEY=
|
||||
# Self-hosted only: allow internal LLM proxy hosts/IPs for LLM connection base URLs.
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST=
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS=
|
||||
# LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS=
|
||||
# LANGFUSE_CACHE_MODEL_MATCH_ENABLED=
|
||||
# LANGFUSE_CACHE_MODEL_MATCH_TTL_SECONDS=
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ updates:
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 8
|
||||
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,41 +33,57 @@ 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
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+398
-105
@@ -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"
|
||||
@@ -232,14 +262,14 @@ jobs:
|
||||
echo "ADMIN_API_KEY=admin-api-key" >> .env
|
||||
echo "LANGFUSE_EE_LICENSE_KEY=langfuse_ee_test" >> .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
|
||||
@@ -310,18 +340,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 +413,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 +476,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 +486,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 +512,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 +565,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 +628,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 +660,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
|
||||
- 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
|
||||
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
|
||||
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
|
||||
- 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 +886,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 +901,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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: Check GitHub Actions
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
merge_group:
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Check GitHub Actions security
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -68,7 +68,7 @@ types:
|
||||
type: BlobStorageIntegrationType
|
||||
bucketName:
|
||||
type: string
|
||||
docs: Name of the storage bucket
|
||||
docs: Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only, must start and end with a letter or number, no consecutive hyphens).
|
||||
endpoint:
|
||||
type: optional<string>
|
||||
docs: Custom endpoint URL (required for S3_COMPATIBLE type)
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "langfuse",
|
||||
"version": "3.167.0",
|
||||
"version": "3.168.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"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import tseslint from "typescript-eslint";
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
|
||||
import turboConfig from "eslint-config-turbo/flat";
|
||||
import "eslint-plugin-only-warn";
|
||||
|
||||
export default tseslint.config(
|
||||
export default [
|
||||
// Global ignores - include config files
|
||||
{
|
||||
name: "langfuse/ignores",
|
||||
@@ -57,17 +56,12 @@ export default tseslint.config(
|
||||
// Prettier (last)
|
||||
eslintPluginPrettierRecommended,
|
||||
|
||||
// TypeScript config for TS files
|
||||
// Note: The old config had a bug (duplicate extends) that prevented TS rules from applying
|
||||
// Only adding parser + plugin + custom rules to match old behavior
|
||||
// Layer repo-specific TS rules on top of Next's built-in flat TS config.
|
||||
// Next already provides the parser and @typescript-eslint plugin here.
|
||||
{
|
||||
name: "langfuse/next/typescript",
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
globals: {
|
||||
React: "readonly",
|
||||
JSX: "readonly",
|
||||
@@ -103,4 +97,4 @@ export default tseslint.config(
|
||||
"react/jsx-key": ["error", { warnOnDuplicates: true }],
|
||||
},
|
||||
},
|
||||
);
|
||||
];
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-config-turbo": "2.9.5",
|
||||
"eslint-plugin-only-warn": "^1.1.0",
|
||||
|
||||
@@ -25,6 +25,7 @@ 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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -84,17 +84,17 @@
|
||||
"@azure/storage-blob": "^12.26.0",
|
||||
"@clickhouse/client": "^1.13.0",
|
||||
"@google-cloud/storage": "^7.19.0",
|
||||
"@langchain/anthropic": "^1.3.12",
|
||||
"@langchain/aws": "^1.3.3",
|
||||
"@langchain/core": "^1.1.34",
|
||||
"@langchain/google-genai": "^2.1.13",
|
||||
"@langchain/google-vertexai": "^2.1.13",
|
||||
"@langchain/openai": "^1.2.3",
|
||||
"@langchain/anthropic": "^1.3.26",
|
||||
"@langchain/aws": "^1.3.4",
|
||||
"@langchain/core": "^1.1.39",
|
||||
"@langchain/google-genai": "^2.1.26",
|
||||
"@langchain/google-vertexai": "^2.1.26",
|
||||
"@langchain/openai": "^1.4.2",
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@react-email/components": "^0.5.1",
|
||||
"@react-email/render": "^1.2.1",
|
||||
"@slack/oauth": "^3.0.4",
|
||||
"@slack/oauth": "3.0.5",
|
||||
"@slack/web-api": "^7.15.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"ajv": "^8.18.0",
|
||||
@@ -108,12 +108,15 @@
|
||||
"ioredis": "^5.8.2",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"jsonpath-plus": "10.3.0",
|
||||
"langchain": "^1.2.15",
|
||||
"langchain": "^1.3.0",
|
||||
"langfuse-langchain": "3.38.20",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"lossless-json": "^4.1.1",
|
||||
"lru-cache": "^11.2.7",
|
||||
"next-auth": "^4.24.13",
|
||||
"nodemailer": "^7.0.11",
|
||||
"oci-objectstorage": "^2.125.0",
|
||||
"oci-common": "^2.125.0",
|
||||
"safe-regex2": "^5.0.0",
|
||||
"undici": "^7.24.6",
|
||||
"uuid": "^9.0.1",
|
||||
|
||||
@@ -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.0";
|
||||
export const VERSION = "v3.168.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(),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -261,6 +288,24 @@ const EnvSchema = z.object({
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((s) =>
|
||||
s ? s.split(",").map((s) => s.toLowerCase().trim()) : [],
|
||||
),
|
||||
SLACK_CLIENT_ID: z.string().optional(),
|
||||
SLACK_CLIENT_SECRET: z.string().optional(),
|
||||
SLACK_STATE_SECRET: z.string().optional(),
|
||||
@@ -272,6 +317,14 @@ const EnvSchema = z.object({
|
||||
.describe(
|
||||
"How many records should be fetched from Slack, before we give up",
|
||||
),
|
||||
SLACK_PAGE_SIZE: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.int()
|
||||
.max(1000)
|
||||
.optional()
|
||||
.default(1000) // Use high default to minimize number of API calls and hence avoid rate limits
|
||||
.describe("Number of channels to fetch per Slack API page"),
|
||||
HTTPS_PROXY: z.string().optional(),
|
||||
|
||||
LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT: z.coerce
|
||||
|
||||
@@ -53,14 +53,23 @@ 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
|
||||
});
|
||||
|
||||
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 +78,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 +86,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
|
||||
|
||||
@@ -54,9 +54,11 @@ export type AuthHeaderValidVerificationResultIngestion = {
|
||||
scope: ApiAccessScopeIngestion;
|
||||
};
|
||||
|
||||
export type ApiAccessLevel = "organization" | "project" | "scores";
|
||||
|
||||
type BaseApiAccessScope = {
|
||||
projectId: string | null;
|
||||
accessLevel: "organization" | "project" | "scores";
|
||||
accessLevel: ApiAccessLevel;
|
||||
};
|
||||
|
||||
type ApiAccessScopeMetadata = {
|
||||
|
||||
+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,8 +29,10 @@ 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";
|
||||
export * from "./llm/getInternalTracingHandler";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
export * from "./utils/transforms";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { URL } from "node:url";
|
||||
import { env } from "../../env";
|
||||
import { logger } from "../logger";
|
||||
import {
|
||||
isHostnameBlocked,
|
||||
isIPBlocked,
|
||||
isIPAddress,
|
||||
} from "../webhooks/ipBlocking";
|
||||
import { resolveHost } from "../webhooks/validation";
|
||||
|
||||
export interface LlmBaseUrlValidationWhitelist {
|
||||
hosts: string[];
|
||||
ips: string[];
|
||||
ip_ranges: string[];
|
||||
}
|
||||
|
||||
export function llmBaseUrlWhitelistFromEnv(): LlmBaseUrlValidationWhitelist {
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION) {
|
||||
return {
|
||||
hosts: [],
|
||||
ips: [],
|
||||
ip_ranges: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hosts: env.LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST || [],
|
||||
ips: env.LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS || [],
|
||||
ip_ranges: env.LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS || [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateLlmConnectionBaseURL(
|
||||
urlString: string,
|
||||
whitelist: LlmBaseUrlValidationWhitelist = llmBaseUrlWhitelistFromEnv(),
|
||||
): Promise<void> {
|
||||
const effectiveWhitelist = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
? {
|
||||
hosts: [],
|
||||
ips: [],
|
||||
ip_ranges: [],
|
||||
}
|
||||
: whitelist;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(normalizeURL(urlString));
|
||||
} catch {
|
||||
throw new Error("Invalid URL syntax");
|
||||
}
|
||||
|
||||
if (!["https:", "http:"].includes(url.protocol)) {
|
||||
throw new Error("Only HTTP and HTTPS protocols are allowed");
|
||||
}
|
||||
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
|
||||
if (effectiveWhitelist.hosts.includes(hostname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHostnameBlocked(hostname)) {
|
||||
throw new Error("Blocked hostname detected");
|
||||
}
|
||||
|
||||
if (env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION && url.protocol !== "https:") {
|
||||
throw new Error("Only HTTPS base URLs are allowed on Langfuse Cloud");
|
||||
}
|
||||
|
||||
if (isIPAddress(hostname)) {
|
||||
if (
|
||||
isIPBlocked(
|
||||
hostname,
|
||||
effectiveWhitelist.ips,
|
||||
effectiveWhitelist.ip_ranges,
|
||||
)
|
||||
) {
|
||||
logger.warn(
|
||||
`LLM base URL validation blocked IP address in hostname: ${hostname}`,
|
||||
);
|
||||
throw new Error("Blocked IP address detected");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let ips: string[];
|
||||
try {
|
||||
ips = await resolveHost(hostname);
|
||||
} catch {
|
||||
// DNS resolution is best-effort here so valid custom gateways do not fail at write time.
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ip of ips) {
|
||||
if (isIPBlocked(ip, effectiveWhitelist.ips, effectiveWhitelist.ip_ranges)) {
|
||||
logger.warn(
|
||||
`LLM base URL validation blocked resolved IP address: ${ip} for hostname: ${hostname}`,
|
||||
);
|
||||
throw new Error("Blocked IP address detected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeURL(urlString: string): string {
|
||||
let normalized = urlString.trim();
|
||||
|
||||
try {
|
||||
normalized = decodeURIComponent(normalized);
|
||||
} catch {
|
||||
throw new Error("Invalid URL encoding");
|
||||
}
|
||||
|
||||
try {
|
||||
normalized = normalized.normalize("NFC");
|
||||
} catch {
|
||||
throw new Error("Invalid unicode in URL");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
let normalized = hostname.toLowerCase();
|
||||
|
||||
try {
|
||||
normalized = new URL(`http://${normalized}`).hostname;
|
||||
} catch {
|
||||
// Keep the original hostname so URL parsing can fail consistently elsewhere.
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -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,
|
||||
@@ -43,12 +44,24 @@ import type { BaseCallbackHandler } from "@langchain/core/callbacks/base";
|
||||
import { ProxyAgent } from "undici";
|
||||
import { getInternalTracingHandler } from "./getInternalTracingHandler";
|
||||
import { decrypt } from "../../encryption";
|
||||
import { decryptAndParseExtraHeaders } from "./utils";
|
||||
import {
|
||||
decryptAndParseExtraHeaders,
|
||||
executeWithRuntimeTimeout,
|
||||
RUNTIME_TIMEOUT_ADAPTERS,
|
||||
} from "./utils";
|
||||
import { logger } from "../logger";
|
||||
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".
|
||||
@@ -86,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 = {
|
||||
@@ -359,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,
|
||||
@@ -453,6 +512,19 @@ export async function fetchLLMCompletion(
|
||||
metadata: traceSinkParams?.metadata,
|
||||
};
|
||||
|
||||
const runtimeTimeoutEnabled = RUNTIME_TIMEOUT_ADAPTERS.has(
|
||||
modelParams.adapter,
|
||||
);
|
||||
const runtimeTimeoutController = runtimeTimeoutEnabled
|
||||
? new AbortController()
|
||||
: undefined;
|
||||
const runConfigWithTimeout = runtimeTimeoutController
|
||||
? {
|
||||
...runConfig,
|
||||
signal: runtimeTimeoutController.signal,
|
||||
}
|
||||
: runConfig;
|
||||
|
||||
const thinkingTypes = getThinkingBlockTypes(modelParams.adapter);
|
||||
|
||||
try {
|
||||
@@ -460,17 +532,24 @@ export async function fetchLLMCompletion(
|
||||
if (params.structuredOutputSchema) {
|
||||
// Thinking-capable adapters may produce reasoning blocks that corrupt JSON schema
|
||||
// parsing. Force function calling so the parser reads from tool_calls instead.
|
||||
const structuredOutputSchema = params.structuredOutputSchema;
|
||||
const structuredOutputConfig =
|
||||
thinkingTypes != null
|
||||
? { method: "functionCalling" as const }
|
||||
: undefined;
|
||||
|
||||
const structuredOutput = await (chatModel as ChatOpenAI)
|
||||
.withStructuredOutput(
|
||||
params.structuredOutputSchema,
|
||||
structuredOutputConfig,
|
||||
)
|
||||
.invoke(finalMessages, runConfig);
|
||||
const structuredOutput = await executeWithRuntimeTimeout({
|
||||
enabled: runtimeTimeoutEnabled,
|
||||
timeoutMs,
|
||||
abortController: runtimeTimeoutController,
|
||||
operation: () =>
|
||||
(chatModel as ChatOpenAI)
|
||||
.withStructuredOutput(
|
||||
structuredOutputSchema,
|
||||
structuredOutputConfig,
|
||||
)
|
||||
.invoke(finalMessages, runConfigWithTimeout),
|
||||
});
|
||||
|
||||
return structuredOutput;
|
||||
}
|
||||
@@ -481,9 +560,15 @@ export async function fetchLLMCompletion(
|
||||
function: tool,
|
||||
}));
|
||||
|
||||
const result = await chatModel
|
||||
.bindTools(langchainTools)
|
||||
.invoke(finalMessages, runConfig);
|
||||
const result = await executeWithRuntimeTimeout({
|
||||
enabled: runtimeTimeoutEnabled,
|
||||
timeoutMs,
|
||||
abortController: runtimeTimeoutController,
|
||||
operation: () =>
|
||||
chatModel
|
||||
.bindTools(langchainTools)
|
||||
.invoke(finalMessages, runConfigWithTimeout),
|
||||
});
|
||||
|
||||
// For thinking adapters, strip reasoning blocks from content before parsing
|
||||
// so ToolCallResponseSchema can validate. Extract reasoning separately.
|
||||
@@ -512,38 +597,52 @@ export async function fetchLLMCompletion(
|
||||
}
|
||||
|
||||
if (streaming)
|
||||
return chatModel
|
||||
.pipe(new BytesOutputParser())
|
||||
.stream(finalMessages, runConfig);
|
||||
return await executeWithRuntimeTimeout({
|
||||
enabled: runtimeTimeoutEnabled,
|
||||
timeoutMs,
|
||||
abortController: runtimeTimeoutController,
|
||||
operation: () =>
|
||||
chatModel
|
||||
.pipe(new BytesOutputParser())
|
||||
.stream(finalMessages, runConfigWithTimeout),
|
||||
});
|
||||
|
||||
// content with thinking blocks can't be handled by StringOutputParser
|
||||
// Invoke model directly and extract text + reasoning separately.
|
||||
if (thinkingTypes != null) {
|
||||
const aiMessage = await chatModel.invoke(finalMessages, runConfig);
|
||||
const aiMessage = await executeWithRuntimeTimeout({
|
||||
enabled: runtimeTimeoutEnabled,
|
||||
timeoutMs,
|
||||
abortController: runtimeTimeoutController,
|
||||
operation: () => chatModel.invoke(finalMessages, runConfigWithTimeout),
|
||||
});
|
||||
return extractCompletionWithReasoning(aiMessage, thinkingTypes);
|
||||
}
|
||||
|
||||
const completion = await chatModel
|
||||
.pipe(new StringOutputParser())
|
||||
.invoke(finalMessages, runConfig);
|
||||
const completion = await executeWithRuntimeTimeout({
|
||||
enabled: runtimeTimeoutEnabled,
|
||||
timeoutMs,
|
||||
abortController: runtimeTimeoutController,
|
||||
operation: () =>
|
||||
chatModel
|
||||
.pipe(new StringOutputParser())
|
||||
.invoke(finalMessages, runConfigWithTimeout),
|
||||
});
|
||||
|
||||
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,83 +1,81 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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",
|
||||
);
|
||||
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;
|
||||
|
||||
if (generationEvents.length === 0) {
|
||||
return null;
|
||||
const blockedSpanIds = new Set();
|
||||
const blockedSpanNames = [
|
||||
"RunnableLambda",
|
||||
"StructuredOutputParser",
|
||||
"StrOutputParser",
|
||||
"JsonOutputParser",
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
const eventName = "name" in event.body ? event.body.name : "";
|
||||
|
||||
if (typeof eventName !== "string" || eventName.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockedSpanNames.includes(eventName as string) && "id" in event.body) {
|
||||
blockedSpanIds.add(event.body.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 events
|
||||
.filter((event) => {
|
||||
if ("id" in event.body) {
|
||||
return !blockedSpanIds.has(event.body.id);
|
||||
}
|
||||
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>) || {},
|
||||
};
|
||||
return true;
|
||||
})
|
||||
.map((event) => {
|
||||
// Inject environment into all events
|
||||
return {
|
||||
...event,
|
||||
body: {
|
||||
...event.body,
|
||||
environment,
|
||||
},
|
||||
};
|
||||
})
|
||||
.map((event) => {
|
||||
if (event.type === "generation-create" && prompt) {
|
||||
return {
|
||||
...event,
|
||||
body: {
|
||||
...event.body,
|
||||
promptName: prompt.name,
|
||||
promptVersion: prompt.version,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return event;
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -90,74 +88,52 @@ export function getInternalTracingHandler(traceSinkParams: TraceSinkParams): {
|
||||
const events = await handler.langfuse._exportLocalEvents(
|
||||
traceSinkParams.targetProjectId,
|
||||
);
|
||||
const processedEvents = prepareInternalTraceEvents({
|
||||
events,
|
||||
environment,
|
||||
prompt,
|
||||
});
|
||||
|
||||
// Filter out unnecessary Langchain spans
|
||||
const blockedSpanIds = new Set();
|
||||
const blockedSpanNames = [
|
||||
"RunnableLambda",
|
||||
"StructuredOutputParser",
|
||||
"StrOutputParser",
|
||||
"JsonOutputParser",
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
const eventName = "name" in event.body ? event.body.name : "";
|
||||
|
||||
if (!eventName) continue;
|
||||
|
||||
if (blockedSpanNames.includes(eventName) && "id" in event.body) {
|
||||
blockedSpanIds.add(event.body.id);
|
||||
}
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
const processedEvents = events
|
||||
.filter((event) => {
|
||||
if ("id" in event.body) {
|
||||
return !blockedSpanIds.has(event.body.id);
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((event: any) => {
|
||||
// to add the prompt name and version to only generation-type observations
|
||||
if (event.type === "generation-create" && prompt) {
|
||||
return {
|
||||
...event,
|
||||
body: {
|
||||
...event.body,
|
||||
...{ promptName: prompt.name, promptVersion: prompt.version },
|
||||
},
|
||||
};
|
||||
}
|
||||
return event;
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,49 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { decrypt } from "../../encryption";
|
||||
import { LLMAdapter } from "./types";
|
||||
|
||||
const ExtraHeaderSchema = z.record(z.string(), z.string());
|
||||
|
||||
export const RUNTIME_TIMEOUT_ADAPTERS = new Set([
|
||||
LLMAdapter.VertexAI,
|
||||
LLMAdapter.GoogleAIStudio,
|
||||
]);
|
||||
|
||||
export async function executeWithRuntimeTimeout<T>({
|
||||
enabled,
|
||||
timeoutMs,
|
||||
abortController,
|
||||
operation,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
abortController?: AbortController;
|
||||
operation: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
if (!enabled) {
|
||||
return operation();
|
||||
}
|
||||
|
||||
const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
abortController?.abort(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptAndParseExtraHeaders(
|
||||
extraHeaders: string | null | undefined,
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import chunk from "lodash/chunk";
|
||||
|
||||
import { prisma } from "../db";
|
||||
|
||||
const BATCH_SIZE = 10_000;
|
||||
|
||||
interface MediaFileRef {
|
||||
id: string;
|
||||
bucketPath: string;
|
||||
@@ -55,16 +59,20 @@ export async function deleteMediaFiles(params: {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Delete from S3 first
|
||||
await storageClient.deleteFiles(mediaFiles.map((f) => f.bucketPath));
|
||||
|
||||
// Delete from PostgreSQL (cascades to traceMedia/observationMedia)
|
||||
await prisma.media.deleteMany({
|
||||
where: {
|
||||
id: { in: mediaFiles.map((f) => f.id) },
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
// Process in batches to stay under PostgreSQL's 32,767 bind variable limit.
|
||||
// S3 is deleted before PG per batch to avoid orphaned storage files.
|
||||
// All callers target expired or soft-deleted media with retry semantics,
|
||||
// so partial failure self-heals on retry (S3 deletes are idempotent).
|
||||
const chunks = chunk(mediaFiles, BATCH_SIZE);
|
||||
for (const batch of chunks) {
|
||||
await storageClient.deleteFiles(batch.map((f) => f.bucketPath));
|
||||
await prisma.media.deleteMany({
|
||||
where: {
|
||||
id: { in: batch.map((f) => f.id) },
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return mediaFiles.length;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -1710,6 +1718,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 +1741,7 @@ const EXPERIMENTS_AGGREGATION_FIELD_SETS = {
|
||||
"prompts",
|
||||
"experimentMetadata",
|
||||
] as const,
|
||||
metrics: ["experimentId", "totalCost", "latencyAvg"] as const,
|
||||
} as const;
|
||||
|
||||
export type ExperimentsAggregationFieldSetName =
|
||||
@@ -1736,9 +1750,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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -14,12 +14,39 @@ import { env } from "../../env";
|
||||
import { prisma } from "../../db";
|
||||
import { encrypt, decrypt } from "../../encryption";
|
||||
|
||||
/**
|
||||
* Error thrown by SlackService when a Slack API call fails.
|
||||
* Preserves the Slack error code so callers can provide user-friendly messages.
|
||||
*/
|
||||
export class SlackApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly slackErrorCode?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SlackApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/** OAuth scopes requested when installing the Slack app. */
|
||||
export const SLACK_BOT_SCOPES = [
|
||||
"channels:read", // read public channels
|
||||
"groups:read", // read private channels that the bot is a member of
|
||||
"chat:write", // send messages to channels the bot is a member of
|
||||
"chat:write.public", // send messages to public channels that the bot is not a member of
|
||||
] as const;
|
||||
|
||||
// Types for Slack integration
|
||||
export interface SlackChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isMember: boolean;
|
||||
isPrivate?: boolean;
|
||||
isMember?: boolean;
|
||||
}
|
||||
|
||||
export interface GetChannelsResult {
|
||||
channels: SlackChannel[];
|
||||
hasPrivateChannelAccess: boolean;
|
||||
}
|
||||
|
||||
export interface SlackMessageParams {
|
||||
@@ -96,7 +123,7 @@ export class SlackService {
|
||||
clientSecret: env.SLACK_CLIENT_SECRET!,
|
||||
stateSecret: env.SLACK_STATE_SECRET!,
|
||||
installUrlOptions: {
|
||||
scopes: ["channels:read", "chat:write", "chat:write.public"],
|
||||
scopes: SLACK_BOT_SCOPES as unknown as string[],
|
||||
},
|
||||
installationStore: {
|
||||
storeInstallation: async (installation) => {
|
||||
@@ -280,7 +307,9 @@ export class SlackService {
|
||||
throw new Error("No bot token found for project");
|
||||
}
|
||||
|
||||
const client = new WebClient(auth.botToken);
|
||||
const client = new WebClient(auth.botToken, {
|
||||
retryConfig: { retries: 3, maxRetryTime: 90_000 },
|
||||
});
|
||||
logger.debug("Created WebClient for project", { projectId });
|
||||
|
||||
return client;
|
||||
@@ -301,14 +330,15 @@ export class SlackService {
|
||||
*/
|
||||
private async getChannelsRecursive(
|
||||
client: WebClient,
|
||||
channelTypes: string = "public_channel,private_channel",
|
||||
cursor?: string,
|
||||
fetchedRecords: number = 0,
|
||||
): Promise<SlackChannel[]> {
|
||||
try {
|
||||
const result = await client.conversations.list({
|
||||
exclude_archived: true,
|
||||
types: "public_channel",
|
||||
limit: 200,
|
||||
types: channelTypes,
|
||||
limit: env.SLACK_PAGE_SIZE,
|
||||
cursor: cursor,
|
||||
});
|
||||
|
||||
@@ -333,10 +363,11 @@ export class SlackService {
|
||||
try {
|
||||
const nextPageChannels = await this.getChannelsRecursive(
|
||||
client,
|
||||
channelTypes,
|
||||
nextCursor,
|
||||
fetchedRecords + channels.length,
|
||||
);
|
||||
return [...channels, ...nextPageChannels];
|
||||
return channels.concat(nextPageChannels);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to retrieve next page of channels, returning only already fetched`,
|
||||
@@ -347,6 +378,55 @@ export class SlackService {
|
||||
return channels;
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch channels recursively", { error, cursor });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channels accessible to the bot.
|
||||
*/
|
||||
async getChannels(client: WebClient): Promise<GetChannelsResult> {
|
||||
try {
|
||||
const channels = await this.getChannelsRecursive(
|
||||
client,
|
||||
"public_channel,private_channel",
|
||||
);
|
||||
|
||||
logger.debug("Retrieved channels from Slack", {
|
||||
channelCount: channels.length,
|
||||
});
|
||||
|
||||
return { channels, hasPrivateChannelAccess: true };
|
||||
} catch (error: any) {
|
||||
// we added `groups:read` scope after initial release, so older installations may not have it.
|
||||
// Detect this case and fall back to fetching only public channels instead of failing completely.
|
||||
const isMissingGroupsRead =
|
||||
error?.data?.error === "missing_scope" &&
|
||||
error?.data?.needed === "groups:read";
|
||||
|
||||
if (isMissingGroupsRead) {
|
||||
logger.info(
|
||||
"Bot token lacks groups:read scope, falling back to public channels only",
|
||||
);
|
||||
|
||||
try {
|
||||
const channels = await this.getChannelsRecursive(
|
||||
client,
|
||||
"public_channel",
|
||||
);
|
||||
|
||||
return { channels, hasPrivateChannelAccess: false };
|
||||
} catch (fallbackError) {
|
||||
logger.error("Failed to fetch public channels fallback", {
|
||||
error: fallbackError,
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to fetch channels: ${fallbackError instanceof Error ? fallbackError.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("Failed to fetch channels", { error });
|
||||
throw new Error(
|
||||
`Failed to fetch channels: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
@@ -354,22 +434,24 @@ export class SlackService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channels accessible to the bot
|
||||
* Get channel info by ID via conversations.info.
|
||||
*/
|
||||
async getChannels(client: WebClient): Promise<SlackChannel[]> {
|
||||
async getChannelInfo(
|
||||
client: WebClient,
|
||||
channelId: string,
|
||||
): Promise<SlackChannel | null> {
|
||||
try {
|
||||
const channels = await this.getChannelsRecursive(client);
|
||||
|
||||
logger.debug("Retrieved channels from Slack", {
|
||||
channelCount: channels.length,
|
||||
});
|
||||
|
||||
return channels;
|
||||
const result = await client.conversations.info({ channel: channelId });
|
||||
if (!result.ok || !result.channel) return null;
|
||||
return {
|
||||
id: result.channel.id!,
|
||||
name: result.channel.name!,
|
||||
isPrivate: result.channel.is_private || false,
|
||||
isMember: result.channel.is_member || false,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch channels", { error });
|
||||
throw new Error(
|
||||
`Failed to fetch channels: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
logger.warn("Failed to fetch channel info", { error, channelId });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,13 +483,16 @@ export class SlackService {
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
logger.error("Failed to send message", {
|
||||
error,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
throw new Error(
|
||||
|
||||
const slackErrorCode = error?.data?.error as string | undefined;
|
||||
throw new SlackApiError(
|
||||
`Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
slackErrorCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1009,3 +1022,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 ",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -50,12 +50,14 @@ export function isIPBlocked(
|
||||
whiteListedIpSegments: string[],
|
||||
): boolean {
|
||||
try {
|
||||
const cleanedIp = normalizeIPAddress(ipString);
|
||||
|
||||
// Check if IP is in whitelist first
|
||||
if (whitelistedIPs.includes(ipString.toLowerCase().trim())) {
|
||||
if (whitelistedIPs.includes(cleanedIp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ip = ipaddr.parse(ipString);
|
||||
const ip = ipaddr.parse(cleanedIp);
|
||||
|
||||
const whitelistedSegments = whiteListedIpSegments.map((cidr) => {
|
||||
const [addr, bits] = cidr.split("/");
|
||||
@@ -87,8 +89,7 @@ export function isIPBlocked(
|
||||
* Check if a string is an IP address
|
||||
*/
|
||||
export function isIPAddress(hostname: string): boolean {
|
||||
// Remove brackets from IPv6 addresses
|
||||
const cleaned = hostname.replace(/^\[|\]$/g, "");
|
||||
const cleaned = normalizeIPAddress(hostname);
|
||||
|
||||
try {
|
||||
ipaddr.parse(cleaned);
|
||||
@@ -137,3 +138,10 @@ export function isHostnameBlocked(hostname: string): boolean {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeIPAddress(ipString: string): string {
|
||||
return ipString
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/^\[|\]$/g, "");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ type OmitKeys<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
|
||||
/**
|
||||
* Removes specified keys from an object and returns a new object without those keys.
|
||||
*/
|
||||
|
||||
export function removeObjectKeys<T, K extends keyof T>(
|
||||
obj: T,
|
||||
keys: K[],
|
||||
@@ -14,3 +13,62 @@ export function removeObjectKeys<T, K extends keyof T>(
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely coerces a value to a Record if it's a plain object.
|
||||
* Returns undefined for null, undefined, arrays, and non-objects.
|
||||
*/
|
||||
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely coerces a value to a string if it's a non-empty string.
|
||||
* Returns undefined for empty strings and non-strings.
|
||||
*/
|
||||
export function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely coerces a value to a boolean if it's a boolean.
|
||||
* Returns undefined for non-booleans.
|
||||
*/
|
||||
export function asBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely coerces a value to a string array if it's an array of strings.
|
||||
* Returns undefined for non-arrays or arrays with non-string elements.
|
||||
*/
|
||||
export function asStringArray(value: unknown): string[] | undefined {
|
||||
return Array.isArray(value) &&
|
||||
value.every((entry) => typeof entry === "string")
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely coerces a value to a Record<string, number> if it's an object with numeric values.
|
||||
* Filters out non-finite numbers. Returns undefined if result is empty or input is not an object.
|
||||
*/
|
||||
export function asNumberRecord(
|
||||
value: unknown,
|
||||
): Record<string, number> | undefined {
|
||||
const record = asRecord(value);
|
||||
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(record).filter(
|
||||
([, entry]) => typeof entry === "number" && Number.isFinite(entry),
|
||||
),
|
||||
) as Record<string, number>;
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
Generated
+1373
-2513
File diff suppressed because it is too large
Load Diff
+27
-63
@@ -4,72 +4,12 @@ packages:
|
||||
- "worker"
|
||||
- "packages/**"
|
||||
- "ee"
|
||||
# 8 day delay for new dep upgrades to reduce supply chain attack risk
|
||||
minimumReleaseAge: 11520
|
||||
# 5 day delay for new dep upgrades to reduce supply chain attack risk
|
||||
minimumReleaseAge: 7200
|
||||
# TODO: remove exclusions below!
|
||||
# the exclusions are temporary so that we can set the 8 day limit without downgrading packages.
|
||||
# the exclusions are temporary so that we can set the 5 day limit without downgrading packages.
|
||||
# this list is version-specific
|
||||
minimumReleaseAgeExclude:
|
||||
- "picomatch@4.0.4"
|
||||
- "graphql@16.13.2"
|
||||
- "use-sync-external-store@1.6.0"
|
||||
- "release-it@19.2.4"
|
||||
- "@codemirror/language@6.12.3"
|
||||
- "@sentry/core@10.46.0"
|
||||
- "@sentry/node-core@10.46.0"
|
||||
- "@sentry-internal/browser-utils@10.46.0"
|
||||
- "@sentry-internal/replay@10.46.0"
|
||||
- "@sentry/opentelemetry@10.46.0"
|
||||
- "@sentry-internal/feedback@10.46.0"
|
||||
- "@sentry-internal/replay-canvas@10.46.0"
|
||||
- "@sentry/browser@10.46.0"
|
||||
- "@sentry/node@10.46.0"
|
||||
- "@sentry/react@10.46.0"
|
||||
- "@sentry/vercel-edge@10.46.0"
|
||||
- "@sentry/nextjs@10.46.0"
|
||||
- "@opentelemetry/context-async-hooks@2.6.1"
|
||||
- "@opentelemetry/core@2.6.1"
|
||||
- "@opentelemetry/resources@2.6.1"
|
||||
- "@opentelemetry/sdk-trace-base@2.6.1"
|
||||
- "undici@7.24.6"
|
||||
- "eslint-plugin-react-hooks@7.0.1"
|
||||
- "react-is@19.2.4"
|
||||
- "@next/swc-darwin-arm64@16.2.1"
|
||||
- "@next/swc-darwin-x64@16.2.1"
|
||||
- "@next/swc-linux-arm64-gnu@16.2.1"
|
||||
- "@next/swc-linux-arm64-musl@16.2.1"
|
||||
- "@next/swc-linux-x64-gnu@16.2.1"
|
||||
- "@next/swc-linux-x64-musl@16.2.1"
|
||||
- "@next/swc-win32-arm64-msvc@16.2.1"
|
||||
- "@next/swc-win32-x64-msvc@16.2.1"
|
||||
- "eslint-config-next@16.2.1"
|
||||
- "@next/eslint-plugin-next@16.2.1"
|
||||
- "@next/env@16.2.1"
|
||||
- "next@16.2.1"
|
||||
- "@vitest/pretty-format@4.1.2"
|
||||
- "@vitest/spy@4.1.2"
|
||||
- "@vitest/utils@4.1.2"
|
||||
- "@vitest/mocker@4.1.2"
|
||||
- "@vitest/runner@4.1.2"
|
||||
- "@vitest/snapshot@4.1.2"
|
||||
- "@vitest/expect@4.1.2"
|
||||
- "vitest@4.1.2"
|
||||
- "@vitest/coverage-v8@4.1.2"
|
||||
- "path-to-regexp@8.3.0"
|
||||
- "lodash@4.17.23"
|
||||
- "zod-to-json-schema@3.25.2"
|
||||
- "langfuse@3.38.20"
|
||||
- "langfuse-langchain@3.38.20"
|
||||
- "langfuse-core@3.38.20"
|
||||
- "@modelcontextprotocol/sdk@1.29.0"
|
||||
- "@prisma/instrumentation@6.19.3"
|
||||
- "prisma@6.19.3"
|
||||
- "@prisma/client@6.19.3"
|
||||
- "@prisma/config@6.19.3"
|
||||
- "@prisma/engines@6.19.3"
|
||||
- "@prisma/debug@6.19.3"
|
||||
- "@prisma/fetch-engine@6.19.3"
|
||||
- "@prisma/get-platform@6.19.3"
|
||||
- "eslint-config-turbo@2.9.5"
|
||||
- "eslint-plugin-turbo@2.9.5"
|
||||
- "turbo@2.9.5"
|
||||
@@ -79,6 +19,30 @@ minimumReleaseAgeExclude:
|
||||
- "@turbo/windows-arm64@2.9.5"
|
||||
- "@turbo/linux-64@2.9.5"
|
||||
- "@turbo/linux-arm64@2.9.5"
|
||||
- "vitest@4.1.4"
|
||||
- "@vitest/mocker@4.1.4"
|
||||
- "@vitest/pretty-format@4.1.4"
|
||||
- "@vitest/snapshot@4.1.4"
|
||||
- "@vitest/spy@4.1.4"
|
||||
- "@vitest/runner@4.1.4"
|
||||
- "@vitest/expect@4.1.4"
|
||||
- "@vitest/utils@4.1.4"
|
||||
- "@vitest/coverage-v8@4.1.4"
|
||||
- "axios@1.15.0"
|
||||
- "next@16.2.3"
|
||||
- "@next/env@16.2.3"
|
||||
- "eslint-config-next@16.2.3"
|
||||
- "@next/eslint-plugin-next@16.2.3"
|
||||
- "@next/swc-darwin-arm64@16.2.3"
|
||||
- "@next/swc-darwin-x64@16.2.3"
|
||||
- "@next/swc-linux-arm64-gnu@16.2.3"
|
||||
- "@next/swc-linux-arm64-musl@16.2.3"
|
||||
- "@next/swc-linux-x64-gnu@16.2.3"
|
||||
- "@next/swc-linux-x64-musl@16.2.3"
|
||||
- "@next/swc-win32-arm64-msvc@16.2.3"
|
||||
- "@next/swc-win32-x64-msvc@16.2.3"
|
||||
- "defu@6.1.7"
|
||||
- "hono@4.12.12"
|
||||
allowBuilds:
|
||||
"@prisma/client": true
|
||||
"@prisma/engines": true
|
||||
|
||||
Executable
+520
@@ -0,0 +1,520 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CODEX_SERVICES_ROOT="${CODEX_SERVICES_ROOT:-$PWD/.codex/services}"
|
||||
# NOTE: POSTGRES_PORT and POSTGRES_USER are effectively immutable once
|
||||
# `$CODEX_SERVICES_ROOT/postgres/data` is initialized. Changing either value on
|
||||
# reruns requires deleting the initialized Postgres data directory and allowing
|
||||
# `initdb` to recreate the cluster with the new settings.
|
||||
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||
REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
CLICKHOUSE_HTTP_PORT="${CLICKHOUSE_HTTP_PORT:-8123}"
|
||||
CLICKHOUSE_NATIVE_PORT="${CLICKHOUSE_NATIVE_PORT:-9000}"
|
||||
MINIO_API_PORT="${MINIO_API_PORT:-9090}"
|
||||
MINIO_CONSOLE_PORT="${MINIO_CONSOLE_PORT:-9091}"
|
||||
|
||||
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-postgres}"
|
||||
POSTGRES_DB="${POSTGRES_DB:-postgres}"
|
||||
REDIS_AUTH="${REDIS_AUTH:-myredissecret}"
|
||||
CLICKHOUSE_USER="${CLICKHOUSE_USER:-clickhouse}"
|
||||
CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-clickhouse}"
|
||||
MINIO_ROOT_USER="${MINIO_ROOT_USER:-minio}"
|
||||
MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD:-miniosecret}"
|
||||
|
||||
MINIO_RELEASE_TAG="${MINIO_RELEASE_TAG:-RELEASE.2025-09-07T16-13-09Z}"
|
||||
MC_RELEASE_TAG="${MC_RELEASE_TAG:-RELEASE.2025-08-13T08-35-41Z}"
|
||||
MINIO_SHA256_AMD64="${MINIO_SHA256_AMD64:-7c5bd8512c6e966455b1d198209358b2d191c77a83ab377c4073281065fb855f}"
|
||||
MINIO_SHA256_ARM64="${MINIO_SHA256_ARM64:-5c83cd2cf151717ba0243f73e1c7802ff36e272b67144bdd7f1f7d684fd6f03d}"
|
||||
MC_SHA256_AMD64="${MC_SHA256_AMD64:-01f866e9c5f9b87c2b09116fa5d7c06695b106242d829a8bb32990c00312e891}"
|
||||
MC_SHA256_ARM64="${MC_SHA256_ARM64:-14c8c9616cfce4636add161304353244e8de383b2e2752c0e9dad01d4c27c12c}"
|
||||
MIGRATE_RELEASE_TAG="${MIGRATE_RELEASE_TAG:-v4.19.1}"
|
||||
MIGRATE_SHA256_AMD64="${MIGRATE_SHA256_AMD64:-2ac648fbd1b127b69ab5a7b33cf96212178f71e22379fc50573630c6f4c7ce18}"
|
||||
MIGRATE_SHA256_ARM64="${MIGRATE_SHA256_ARM64:-2fea2455c0f3f07cc3f4b98471c951ad1a716059574b20b6416bd1e9058751c5}"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ensure_apt_package() {
|
||||
local package="$1"
|
||||
|
||||
if dpkg -s "$package" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -z "${CODEX_APT_UPDATED:-}" ]; then
|
||||
apt-get update
|
||||
CODEX_APT_UPDATED=1
|
||||
fi
|
||||
|
||||
apt-get install -y "$package"
|
||||
}
|
||||
|
||||
stop_service_if_running() {
|
||||
local service_name="$1"
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl stop "$service_name" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if command -v service >/dev/null 2>&1; then
|
||||
service "$service_name" stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
stop_system_postgres_clusters() {
|
||||
if command -v pg_lsclusters >/dev/null 2>&1 && command -v pg_ctlcluster >/dev/null 2>&1; then
|
||||
while read -r version cluster_name _ status _; do
|
||||
if [ "$status" = "online" ]; then
|
||||
pg_ctlcluster "$version" "$cluster_name" stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
done < <(pg_lsclusters --no-header 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
stop_service_if_running postgresql
|
||||
}
|
||||
|
||||
ensure_clickhouse_repo() {
|
||||
ensure_apt_package ca-certificates
|
||||
ensure_apt_package curl
|
||||
ensure_apt_package gnupg
|
||||
|
||||
local keyring="/etc/apt/keyrings/clickhouse.gpg"
|
||||
local source_file="/etc/apt/sources.list.d/clickhouse.list"
|
||||
|
||||
mkdir -p /etc/apt/keyrings
|
||||
|
||||
if [ ! -f "$keyring" ]; then
|
||||
curl -fsSL https://packages.clickhouse.com/rpm/lts/repodata/repomd.xml.key \
|
||||
| gpg --dearmor -o "$keyring"
|
||||
fi
|
||||
|
||||
if [ ! -f "$source_file" ]; then
|
||||
echo "deb [signed-by=$keyring] https://packages.clickhouse.com/deb stable main" > "$source_file"
|
||||
apt-get update
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_postgres_binaries() {
|
||||
ensure_apt_package postgresql
|
||||
ensure_apt_package postgresql-client
|
||||
stop_system_postgres_clusters
|
||||
}
|
||||
|
||||
ensure_redis_binary() {
|
||||
ensure_apt_package redis-server
|
||||
stop_service_if_running redis-server
|
||||
}
|
||||
|
||||
ensure_clickhouse_binaries() {
|
||||
if command -v clickhouse-server >/dev/null 2>&1 && command -v clickhouse-client >/dev/null 2>&1; then
|
||||
stop_service_if_running clickhouse-server
|
||||
return 0
|
||||
fi
|
||||
|
||||
ensure_clickhouse_repo
|
||||
apt-get install -y clickhouse-server clickhouse-client
|
||||
stop_service_if_running clickhouse-server
|
||||
}
|
||||
|
||||
detect_migrate_arch() {
|
||||
local machine_arch
|
||||
machine_arch="$(uname -m)"
|
||||
|
||||
case "$machine_arch" in
|
||||
x86_64|amd64)
|
||||
echo "amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
echo "arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture for golang-migrate binary: $machine_arch" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
ensure_migrate_binary() {
|
||||
if command -v migrate >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
ensure_apt_package ca-certificates
|
||||
ensure_apt_package curl
|
||||
|
||||
local migrate_arch
|
||||
local migrate_sha256
|
||||
local tmp_dir
|
||||
migrate_arch="$(detect_migrate_arch)"
|
||||
case "$migrate_arch" in
|
||||
amd64)
|
||||
migrate_sha256="$MIGRATE_SHA256_AMD64"
|
||||
;;
|
||||
arm64)
|
||||
migrate_sha256="$MIGRATE_SHA256_ARM64"
|
||||
;;
|
||||
esac
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' RETURN
|
||||
|
||||
download_and_verify_sha256 \
|
||||
"https://github.com/golang-migrate/migrate/releases/download/${MIGRATE_RELEASE_TAG}/migrate.linux-${migrate_arch}.tar.gz" \
|
||||
"$tmp_dir/migrate.tar.gz" \
|
||||
"$migrate_sha256"
|
||||
tar -xzf "$tmp_dir/migrate.tar.gz" -C "$tmp_dir" migrate
|
||||
install -m 0755 "$tmp_dir/migrate" /usr/local/bin/migrate
|
||||
}
|
||||
|
||||
detect_minio_arch() {
|
||||
local machine_arch
|
||||
machine_arch="$(uname -m)"
|
||||
|
||||
case "$machine_arch" in
|
||||
x86_64|amd64)
|
||||
echo "amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
echo "arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture for MinIO binaries: $machine_arch" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
download_and_verify_sha256() {
|
||||
local url="$1"
|
||||
local output_path="$2"
|
||||
local expected_sha256="$3"
|
||||
local tmp_download
|
||||
|
||||
tmp_download="$(mktemp)"
|
||||
trap 'rm -f "$tmp_download"' RETURN
|
||||
curl -fsSL "$url" -o "$tmp_download"
|
||||
|
||||
local actual_sha256
|
||||
actual_sha256="$(sha256sum "$tmp_download" | awk '{print $1}')"
|
||||
|
||||
if [ "$actual_sha256" != "$expected_sha256" ]; then
|
||||
echo "SHA256 mismatch for $url" >&2
|
||||
echo "expected: $expected_sha256" >&2
|
||||
echo "actual: $actual_sha256" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
mv "$tmp_download" "$output_path"
|
||||
trap - RETURN
|
||||
}
|
||||
|
||||
ensure_minio_binaries() {
|
||||
local bin_dir="$CODEX_SERVICES_ROOT/bin"
|
||||
local minio_arch
|
||||
local minio_sha256
|
||||
local mc_sha256
|
||||
|
||||
mkdir -p "$bin_dir"
|
||||
minio_arch="$(detect_minio_arch)"
|
||||
|
||||
case "$minio_arch" in
|
||||
amd64)
|
||||
minio_sha256="$MINIO_SHA256_AMD64"
|
||||
mc_sha256="$MC_SHA256_AMD64"
|
||||
;;
|
||||
arm64)
|
||||
minio_sha256="$MINIO_SHA256_ARM64"
|
||||
mc_sha256="$MC_SHA256_ARM64"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -x "$bin_dir/minio" ]; then
|
||||
download_and_verify_sha256 \
|
||||
"https://dl.min.io/server/minio/release/linux-${minio_arch}/archive/minio.${MINIO_RELEASE_TAG}" \
|
||||
"$bin_dir/minio" \
|
||||
"$minio_sha256"
|
||||
chmod +x "$bin_dir/minio"
|
||||
fi
|
||||
|
||||
if [ ! -x "$bin_dir/mc" ]; then
|
||||
download_and_verify_sha256 \
|
||||
"https://dl.min.io/client/mc/release/linux-${minio_arch}/archive/mc.${MC_RELEASE_TAG}" \
|
||||
"$bin_dir/mc" \
|
||||
"$mc_sha256"
|
||||
chmod +x "$bin_dir/mc"
|
||||
fi
|
||||
|
||||
export PATH="$bin_dir:$PATH"
|
||||
}
|
||||
|
||||
find_postgres_bin() {
|
||||
local name="$1"
|
||||
|
||||
if command -v "$name" >/dev/null 2>&1; then
|
||||
command -v "$name"
|
||||
return 0
|
||||
fi
|
||||
|
||||
find /usr/lib/postgresql -type f -name "$name" 2>/dev/null | sort -V | tail -n 1
|
||||
}
|
||||
|
||||
wait_for_port() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
local timeout_seconds="${3:-45}"
|
||||
local deadline=$((SECONDS + timeout_seconds))
|
||||
|
||||
until (echo >"/dev/tcp/$host/$port") >/dev/null 2>&1; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1"
|
||||
local timeout_seconds="${2:-45}"
|
||||
local deadline=$((SECONDS + timeout_seconds))
|
||||
|
||||
until curl -fsS "$url" >/dev/null 2>&1; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
escape_sql_literal() {
|
||||
local value="$1"
|
||||
value="${value//\\/\\\\}"
|
||||
printf "%s" "${value//\'/\'\'}"
|
||||
}
|
||||
|
||||
escape_clickhouse_identifier() {
|
||||
local value="$1"
|
||||
printf '`%s`' "${value//\`/\`\`}"
|
||||
}
|
||||
|
||||
escape_redis_config_string() {
|
||||
local value="$1"
|
||||
# Redis treats backslashes and double-quotes as escape delimiters inside
|
||||
# quoted config strings, so both must be escaped before writing requirepass.
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//$'\n'/\\n}"
|
||||
value="${value//\"/\\\"}"
|
||||
printf "%s" "$value"
|
||||
}
|
||||
|
||||
ensure_postgres_running() {
|
||||
ensure_postgres_binaries
|
||||
|
||||
local initdb
|
||||
local pg_ctl
|
||||
local psql
|
||||
local pg_isready
|
||||
|
||||
initdb="$(find_postgres_bin initdb)"
|
||||
pg_ctl="$(find_postgres_bin pg_ctl)"
|
||||
psql="$(find_postgres_bin psql)"
|
||||
pg_isready="$(find_postgres_bin pg_isready)"
|
||||
|
||||
if [ -z "$initdb" ] || [ -z "$pg_ctl" ] || [ -z "$psql" ] || [ -z "$pg_isready" ]; then
|
||||
echo "Unable to find required PostgreSQL binaries (initdb, pg_ctl, psql, pg_isready)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local pg_root="$CODEX_SERVICES_ROOT/postgres"
|
||||
local pg_data="$pg_root/data"
|
||||
local pg_log="$pg_root/postgres.log"
|
||||
local pg_socket_dir="$pg_root"
|
||||
local -a pg_runner
|
||||
|
||||
mkdir -p "$pg_root"
|
||||
|
||||
if [ "${EUID:-$(id -u)}" -eq 0 ] && id -u postgres >/dev/null 2>&1; then
|
||||
chown -R postgres:postgres "$pg_root"
|
||||
pg_runner=(runuser -u postgres --)
|
||||
else
|
||||
pg_runner=()
|
||||
fi
|
||||
|
||||
if [ ! -f "$pg_data/PG_VERSION" ]; then
|
||||
"${pg_runner[@]}" "$initdb" -D "$pg_data" -U "$POSTGRES_USER" --auth-host=md5 --auth-local=trust >/dev/null
|
||||
{
|
||||
echo "listen_addresses = '127.0.0.1'"
|
||||
echo "port = $POSTGRES_PORT"
|
||||
echo "log_statement = 'all'"
|
||||
echo "timezone = 'UTC'"
|
||||
echo "unix_socket_directories = '$pg_socket_dir'"
|
||||
} >> "$pg_data/postgresql.conf"
|
||||
fi
|
||||
|
||||
if ! "${pg_runner[@]}" "$pg_ctl" -D "$pg_data" status >/dev/null 2>&1; then
|
||||
"${pg_runner[@]}" "$pg_ctl" -D "$pg_data" -l "$pg_log" -w start
|
||||
fi
|
||||
|
||||
if ! "$pg_isready" -h "$pg_socket_dir" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" >/dev/null 2>&1; then
|
||||
echo "PostgreSQL did not become ready on socket $pg_socket_dir (port $POSTGRES_PORT)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PGPASSWORD="${POSTGRES_PASSWORD}" "${pg_runner[@]}" "$psql" -h "$pg_socket_dir" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d postgres -v postgres_user="$POSTGRES_USER" -v postgres_db="$POSTGRES_DB" -v postgres_password="$POSTGRES_PASSWORD" <<SQL >/dev/null
|
||||
SELECT format('ALTER USER %I WITH PASSWORD %L', :'postgres_user', :'postgres_password')\gexec
|
||||
SELECT format('CREATE DATABASE %I', :'postgres_db')
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'postgres_db')\gexec
|
||||
SQL
|
||||
}
|
||||
|
||||
ensure_redis_running() {
|
||||
ensure_redis_binary
|
||||
|
||||
local redis_root="$CODEX_SERVICES_ROOT/redis"
|
||||
local redis_conf="$redis_root/redis.conf"
|
||||
local redis_log="$redis_root/redis.log"
|
||||
local redis_pid="$redis_root/redis.pid"
|
||||
local redis_auth_escaped
|
||||
|
||||
mkdir -p "$redis_root"
|
||||
|
||||
if wait_for_port 127.0.0.1 "$REDIS_PORT" 1; then
|
||||
echo "Redis already running on 127.0.0.1:$REDIS_PORT; keeping existing runtime config."
|
||||
return 0
|
||||
fi
|
||||
|
||||
redis_auth_escaped="$(escape_redis_config_string "$REDIS_AUTH")"
|
||||
|
||||
cat > "$redis_conf" <<CONF
|
||||
bind 127.0.0.1
|
||||
port $REDIS_PORT
|
||||
requirepass "$redis_auth_escaped"
|
||||
maxmemory-policy noeviction
|
||||
daemonize yes
|
||||
pidfile "$redis_pid"
|
||||
logfile "$redis_log"
|
||||
dir "$redis_root"
|
||||
CONF
|
||||
|
||||
redis-server "$redis_conf"
|
||||
|
||||
if ! wait_for_port 127.0.0.1 "$REDIS_PORT" 30; then
|
||||
echo "Redis did not start on 127.0.0.1:$REDIS_PORT"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_clickhouse_running() {
|
||||
ensure_clickhouse_binaries
|
||||
|
||||
local clickhouse_root="$CODEX_SERVICES_ROOT/clickhouse"
|
||||
local clickhouse_data="$clickhouse_root/data"
|
||||
local clickhouse_log="$clickhouse_root/clickhouse.log"
|
||||
local clickhouse_err="$clickhouse_root/clickhouse.err.log"
|
||||
local clickhouse_pid="$clickhouse_root/clickhouse.pid"
|
||||
local -a clickhouse_runner
|
||||
|
||||
mkdir -p "$clickhouse_data"
|
||||
if [ "${EUID:-$(id -u)}" -eq 0 ] && id -u clickhouse >/dev/null 2>&1; then
|
||||
chown -R clickhouse:clickhouse "$clickhouse_root"
|
||||
clickhouse_runner=(runuser -u clickhouse --)
|
||||
else
|
||||
clickhouse_runner=()
|
||||
fi
|
||||
|
||||
if ! wait_for_http "http://127.0.0.1:$CLICKHOUSE_HTTP_PORT/ping" 1; then
|
||||
"${clickhouse_runner[@]}" clickhouse-server \
|
||||
--daemon \
|
||||
--config-file=/etc/clickhouse-server/config.xml \
|
||||
--pid-file="$clickhouse_pid" \
|
||||
--log-file="$clickhouse_log" \
|
||||
--errorlog-file="$clickhouse_err" \
|
||||
-- \
|
||||
--path="$clickhouse_data" \
|
||||
--http_port="$CLICKHOUSE_HTTP_PORT" \
|
||||
--tcp_port="$CLICKHOUSE_NATIVE_PORT"
|
||||
fi
|
||||
|
||||
if ! wait_for_http "http://127.0.0.1:$CLICKHOUSE_HTTP_PORT/ping" 45; then
|
||||
echo "ClickHouse did not start on 127.0.0.1:$CLICKHOUSE_HTTP_PORT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local clickhouse_password_sql
|
||||
local clickhouse_user_identifier
|
||||
clickhouse_password_sql="$(escape_sql_literal "$CLICKHOUSE_PASSWORD")"
|
||||
clickhouse_user_identifier="$(escape_clickhouse_identifier "$CLICKHOUSE_USER")"
|
||||
|
||||
clickhouse-client --host 127.0.0.1 --port "$CLICKHOUSE_NATIVE_PORT" -q "CREATE USER IF NOT EXISTS $clickhouse_user_identifier IDENTIFIED WITH plaintext_password BY '$clickhouse_password_sql'"
|
||||
if ! clickhouse-client --host 127.0.0.1 --port "$CLICKHOUSE_NATIVE_PORT" -q "GRANT CURRENT GRANTS ON *.* TO $clickhouse_user_identifier" >/dev/null 2>&1; then
|
||||
clickhouse-client --host 127.0.0.1 --port "$CLICKHOUSE_NATIVE_PORT" -q "GRANT ALL ON *.* TO $clickhouse_user_identifier WITH GRANT OPTION"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_minio_running() {
|
||||
ensure_minio_binaries
|
||||
|
||||
local minio_root="$CODEX_SERVICES_ROOT/minio"
|
||||
local minio_data="$minio_root/data"
|
||||
local minio_log="$minio_root/minio.log"
|
||||
local minio_pid="$minio_root/minio.pid"
|
||||
local minio_already_running="false"
|
||||
|
||||
mkdir -p "$minio_data"
|
||||
|
||||
if wait_for_port 127.0.0.1 "$MINIO_API_PORT" 1; then
|
||||
echo "MinIO already running on 127.0.0.1:$MINIO_API_PORT; skipping server start."
|
||||
minio_already_running="true"
|
||||
fi
|
||||
|
||||
if [ "$minio_already_running" != "true" ]; then
|
||||
(
|
||||
export MINIO_ROOT_USER MINIO_ROOT_PASSWORD
|
||||
nohup minio server \
|
||||
--address "127.0.0.1:$MINIO_API_PORT" \
|
||||
--console-address "127.0.0.1:$MINIO_CONSOLE_PORT" \
|
||||
"$minio_data" >"$minio_log" 2>&1 &
|
||||
echo $! > "$minio_pid"
|
||||
)
|
||||
fi
|
||||
|
||||
if ! wait_for_port 127.0.0.1 "$MINIO_API_PORT" 45; then
|
||||
echo "MinIO did not start on 127.0.0.1:$MINIO_API_PORT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! mc alias set local "http://127.0.0.1:$MINIO_API_PORT" "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null 2>&1; then
|
||||
if [ "$minio_already_running" = "true" ]; then
|
||||
echo "MinIO is running but credentials do not match MINIO_ROOT_USER/MINIO_ROOT_PASSWORD; skipping bucket reconciliation."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Failed to configure MinIO client alias for fresh MinIO startup."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! mc mb --ignore-existing local/langfuse >/dev/null 2>&1; then
|
||||
if [ "$minio_already_running" = "true" ]; then
|
||||
echo "Failed to reconcile MinIO bucket 'langfuse'; will retry on next run."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Failed to create MinIO bucket 'langfuse' after fresh startup."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_cloud_dependencies() {
|
||||
mkdir -p "$CODEX_SERVICES_ROOT"
|
||||
|
||||
ensure_migrate_binary
|
||||
ensure_postgres_running
|
||||
ensure_redis_running
|
||||
ensure_clickhouse_running
|
||||
ensure_minio_running
|
||||
|
||||
echo "Cloud dependencies are installed and running:"
|
||||
echo "- PostgreSQL on 127.0.0.1:$POSTGRES_PORT"
|
||||
echo "- Redis on 127.0.0.1:$REDIS_PORT"
|
||||
echo "- ClickHouse HTTP on 127.0.0.1:$CLICKHOUSE_HTTP_PORT, native on 127.0.0.1:$CLICKHOUSE_NATIVE_PORT"
|
||||
echo "- MinIO API on 127.0.0.1:$MINIO_API_PORT, console on 127.0.0.1:$MINIO_CONSOLE_PORT"
|
||||
}
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v corepack >/dev/null 2>&1; then
|
||||
echo "corepack is required. Use a Codex base environment with Node.js 24 support."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/cloud_services.sh"
|
||||
ensure_cloud_dependencies
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Keep generated Prisma artifacts aligned after dependency or schema updates.
|
||||
pnpm run db:generate
|
||||
|
||||
# Keep local databases initialized for worker/web tests during maintenance runs.
|
||||
pnpm --filter=shared run db:reset:test
|
||||
pnpm --filter=shared run db:reset -f
|
||||
SKIP_CONFIRM=1 pnpm --filter=shared run ch:reset
|
||||
pnpm --filter=shared run db:seed:examples
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ensure_env_file() {
|
||||
local target_path="$1"
|
||||
local fallback_path="$2"
|
||||
|
||||
if [ -f "$target_path" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
cp "$fallback_path" "$target_path"
|
||||
}
|
||||
|
||||
if ! command -v corepack >/dev/null 2>&1; then
|
||||
echo "corepack is required. Use a Codex base environment with Node.js 24 support."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
ensure_env_file .env .env.dev.example
|
||||
ensure_env_file .env.test .env.test.example
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/cloud_services.sh"
|
||||
ensure_cloud_dependencies
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Install Chromium into the default user-level Playwright cache so frontend
|
||||
# browser review works on first bootstrap.
|
||||
pnpm run playwright:install
|
||||
|
||||
# Generate the shared Prisma client explicitly in the current worktree before
|
||||
# the workspace-wide db:generate task, which may be satisfied by Turbo cache.
|
||||
pnpm --filter=shared run db:generate
|
||||
|
||||
# Prisma client generation is needed for typecheck/build tasks in Codex.
|
||||
pnpm run db:generate
|
||||
|
||||
# Initialize local databases so worker/web tests can run immediately after
|
||||
# bootstrap without "table does not exist" failures.
|
||||
pnpm --filter=shared run db:reset:test
|
||||
pnpm --filter=shared run db:reset -f
|
||||
SKIP_CONFIRM=1 pnpm --filter=shared run ch:reset
|
||||
pnpm --filter=shared run db:seed:examples
|
||||
+15
-3
@@ -19,6 +19,20 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS runtime-base
|
||||
RUN rm -rf /usr/local/lib/node_modules/corepack && \
|
||||
rm -f /usr/local/bin/corepack /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
|
||||
FROM --platform=${BUILDPLATFORM} golang:1.24 AS migrate-builder
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ENV CGO_ENABLED=0 \
|
||||
GOBIN=/out \
|
||||
GOOS=${TARGETOS} \
|
||||
GOARCH=${TARGETARCH}
|
||||
# Build only the ClickHouse migrate CLI Langfuse uses at runtime.
|
||||
# compile this ourselves instead of downloading the upstream release
|
||||
# because prebuilt bins bundle many unused drivers and thus inherit CVEs
|
||||
# eg.: https://github.com/golang-migrate/migrate/issues/1357
|
||||
RUN /usr/local/go/bin/go install -trimpath -tags 'clickhouse' -ldflags='-s -w' \
|
||||
github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} build-base AS pruner
|
||||
|
||||
WORKDIR /app
|
||||
@@ -134,9 +148,7 @@ RUN if [ -n "$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION" ]; then \
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm && \
|
||||
rm -f /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
RUN MIGRATE_TARGET_ARCH=$(echo ${TARGETPLATFORM:-linux/amd64} | sed 's/\//-/g') && \
|
||||
wget -q -O- https://github.com/golang-migrate/migrate/releases/download/v4.19.1/migrate.$MIGRATE_TARGET_ARCH.tar.gz | tar xvz && \
|
||||
mv migrate /usr/bin/migrate
|
||||
COPY --from=migrate-builder /out/migrate /usr/bin/migrate
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/next.config.mjs .
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/web/package.json .
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.167.0",
|
||||
"version": "3.168.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -43,7 +43,7 @@
|
||||
"@headlessui/tailwindcss": "0.2.2",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@langchain/core": "^1.1.34",
|
||||
"@langchain/core": "^1.1.39",
|
||||
"@langfuse/ee": "workspace:*",
|
||||
"@langfuse/shared": "workspace:*",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
@@ -122,12 +122,12 @@
|
||||
"ioredis": "^5.8.2",
|
||||
"ip-address": "^9.0.5",
|
||||
"json-schema-faker": "^0.5.9",
|
||||
"langchain": "^1.2.15",
|
||||
"langchain": "^1.3.0",
|
||||
"langfuse": "3.38.4",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.552.0",
|
||||
"nanoid": "^3.3.11",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-query-params": "^5.1.0",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -192,10 +192,10 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20260122.3",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"node-mocks-http": "^1.14.1",
|
||||
"node-mocks-http": "^1.17.2",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
|
||||
@@ -6325,7 +6325,11 @@ components:
|
||||
$ref: '#/components/schemas/BlobStorageIntegrationType'
|
||||
bucketName:
|
||||
type: string
|
||||
description: Name of the storage bucket
|
||||
description: >-
|
||||
Name of the storage bucket. For AZURE_BLOB_STORAGE, must be a valid
|
||||
Azure container name (3-63 chars, lowercase letters, numbers, and
|
||||
hyphens only, must start and end with a letter or number, no
|
||||
consecutive hyphens).
|
||||
endpoint:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
@@ -1,42 +1,37 @@
|
||||
import { getExperimentsAccess } from "@/src/features/experiments/utils/experimentsAccess";
|
||||
|
||||
describe("getExperimentsAccess", () => {
|
||||
it("returns enabled only when cloud, v4 beta, and admin/flag gate all pass", () => {
|
||||
const enabledViaAdmin = getExperimentsAccess({
|
||||
isLangfuseCloud: true,
|
||||
isV4BetaEnabled: true,
|
||||
isAdmin: true,
|
||||
isFeatureEnabledOnUser: false,
|
||||
});
|
||||
|
||||
const enabledViaFlag = getExperimentsAccess({
|
||||
isLangfuseCloud: true,
|
||||
isV4BetaEnabled: true,
|
||||
isAdmin: false,
|
||||
isFeatureEnabledOnUser: true,
|
||||
});
|
||||
|
||||
expect(enabledViaAdmin.isEnabled).toBe(true);
|
||||
expect(enabledViaFlag.isEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("returns disabled when v4 beta is off even for eligible users", () => {
|
||||
it("returns enabled when cloud and v4 beta are both enabled", () => {
|
||||
const access = getExperimentsAccess({
|
||||
isLangfuseCloud: true,
|
||||
isV4BetaEnabled: false,
|
||||
isAdmin: true,
|
||||
isFeatureEnabledOnUser: true,
|
||||
isV4BetaEnabled: true,
|
||||
});
|
||||
|
||||
expect(access.isEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("returns disabled when not on cloud", () => {
|
||||
const access = getExperimentsAccess({
|
||||
isLangfuseCloud: false,
|
||||
isV4BetaEnabled: true,
|
||||
});
|
||||
|
||||
expect(access.isEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("returns disabled when user is neither admin nor flagged", () => {
|
||||
it("returns disabled when v4 beta is off", () => {
|
||||
const access = getExperimentsAccess({
|
||||
isLangfuseCloud: true,
|
||||
isV4BetaEnabled: true,
|
||||
isAdmin: false,
|
||||
isFeatureEnabledOnUser: false,
|
||||
isV4BetaEnabled: false,
|
||||
});
|
||||
|
||||
expect(access.isEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("returns disabled when both cloud and v4 beta are off", () => {
|
||||
const access = getExperimentsAccess({
|
||||
isLangfuseCloud: false,
|
||||
isV4BetaEnabled: false,
|
||||
});
|
||||
|
||||
expect(access.isEnabled).toBe(false);
|
||||
|
||||
@@ -1885,6 +1885,106 @@ describe("OTel Resource Span Mapping", () => {
|
||||
expect(metadataAttributes).not.toHaveProperty("pydantic_ai.all_messages");
|
||||
});
|
||||
|
||||
it("should normalize current Pydantic AI cache usage fields into Langfuse usage details", async () => {
|
||||
const traceId = "abcdef1234567890abcdef1234567891";
|
||||
|
||||
const pydanticAiRootSpan = {
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: "telemetry.sdk.language",
|
||||
value: { stringValue: "python" },
|
||||
},
|
||||
{
|
||||
key: "telemetry.sdk.name",
|
||||
value: { stringValue: "opentelemetry" },
|
||||
},
|
||||
{
|
||||
key: "service.name",
|
||||
value: { stringValue: "test-service" },
|
||||
},
|
||||
],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "pydantic-ai",
|
||||
version: "1.66.0",
|
||||
attributes: [],
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: Buffer.from(traceId, "hex"),
|
||||
spanId: Buffer.from("80854cd6bd218bf6", "hex"),
|
||||
name: "pydantic-cache-test",
|
||||
kind: 1,
|
||||
startTimeUnixNano: {
|
||||
low: 1000000,
|
||||
high: 406528574,
|
||||
unsigned: true,
|
||||
},
|
||||
endTimeUnixNano: {
|
||||
low: 2000000,
|
||||
high: 406528574,
|
||||
unsigned: true,
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: "gen_ai.usage.input_tokens",
|
||||
value: { intValue: { low: 120, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.output_tokens",
|
||||
value: { intValue: { low: 40, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.cache_read.input_tokens",
|
||||
value: { intValue: { low: 30, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.cache_creation.input_tokens",
|
||||
value: { intValue: { low: 10, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.details.input_audio_tokens",
|
||||
value: { intValue: { low: 5, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.details.cache_audio_read_tokens",
|
||||
value: { intValue: { low: 2, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.details.output_audio_tokens",
|
||||
value: { intValue: { low: 7, high: 0, unsigned: false } },
|
||||
},
|
||||
],
|
||||
events: [],
|
||||
status: { code: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const events = await convertOtelSpanToIngestionEvent(
|
||||
pydanticAiRootSpan,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const observationEvent = events.find((e) => e.type === "span-create");
|
||||
|
||||
expect(observationEvent).toBeDefined();
|
||||
expect(observationEvent?.body.usageDetails.input).toBe(80);
|
||||
expect(observationEvent?.body.usageDetails.output).toBe(40);
|
||||
expect(observationEvent?.body.usageDetails.input_cached_tokens).toBe(30);
|
||||
expect(observationEvent?.body.usageDetails.input_cache_creation).toBe(10);
|
||||
expect(observationEvent?.body.usageDetails.input_audio_tokens).toBe(5);
|
||||
expect(observationEvent?.body.usageDetails.cache_audio_read_tokens).toBe(
|
||||
2,
|
||||
);
|
||||
expect(observationEvent?.body.usageDetails.output_audio_tokens).toBe(7);
|
||||
});
|
||||
|
||||
it("should prepend gen_ai.system_instructions to pydantic_ai.all_messages input when system message is absent", async () => {
|
||||
const traceId = "9d7aa9a729def1eadc0b2063ca4ebeb4";
|
||||
|
||||
@@ -6202,6 +6302,92 @@ describe("OTel Resource Span Mapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GenAI usage normalization", () => {
|
||||
it("should normalize official gen_ai cache usage into Langfuse canonical keys", async () => {
|
||||
const traceId = "abcdef1234567890abcdef1234567892";
|
||||
|
||||
const genAiSpan = {
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: "service.name",
|
||||
value: { stringValue: "test-service" },
|
||||
},
|
||||
],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "gen_ai",
|
||||
version: "1.0.0",
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: Buffer.from(traceId, "hex"),
|
||||
spanId: Buffer.from("1234567890abcde1", "hex"),
|
||||
name: "normalized-genai-usage",
|
||||
kind: 1,
|
||||
startTimeUnixNano: {
|
||||
low: 1000000,
|
||||
high: 406528574,
|
||||
unsigned: true,
|
||||
},
|
||||
endTimeUnixNano: {
|
||||
low: 2000000,
|
||||
high: 406528574,
|
||||
unsigned: true,
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: "gen_ai.usage.input_tokens",
|
||||
value: { intValue: { low: 100, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.output_tokens",
|
||||
value: { intValue: { low: 40, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.total_tokens",
|
||||
value: { intValue: { low: 140, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.cache_read.input_tokens",
|
||||
value: { intValue: { low: 20, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.cache_creation.input_tokens",
|
||||
value: { intValue: { low: 10, high: 0, unsigned: false } },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.usage.output_audio_tokens",
|
||||
value: { intValue: { low: 5, high: 0, unsigned: false } },
|
||||
},
|
||||
],
|
||||
status: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const events = await convertOtelSpanToIngestionEvent(
|
||||
genAiSpan,
|
||||
new Set(),
|
||||
);
|
||||
const observationEvent = events.find(
|
||||
(e) => e.type === "generation-create" || e.type === "span-create",
|
||||
);
|
||||
|
||||
expect(observationEvent).toBeDefined();
|
||||
expect(observationEvent?.body.usageDetails.input).toBe(70);
|
||||
expect(observationEvent?.body.usageDetails.output).toBe(40);
|
||||
expect(observationEvent?.body.usageDetails.total).toBe(140);
|
||||
expect(observationEvent?.body.usageDetails.input_cached_tokens).toBe(20);
|
||||
expect(observationEvent?.body.usageDetails.input_cache_creation).toBe(10);
|
||||
expect(observationEvent?.body.usageDetails.output_audio_tokens).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vercel AI SDK Usage details", () => {
|
||||
it("should extract usage details from both provider metadata and 'ai.usage'", async () => {
|
||||
const traceId = "abcdef1234567890abcdef1234567890";
|
||||
@@ -7279,4 +7465,73 @@ describe("OTel Resource Span Mapping", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prototype pollution protection", () => {
|
||||
const publicKey = "pk-lf-1234567890";
|
||||
|
||||
it("should not pollute Object.prototype via __proto__ in gen_ai.prompt attributes", async () => {
|
||||
const traceId = "abcdef1234567890abcdef1234567890";
|
||||
const spanId = "abcdef1234567890";
|
||||
|
||||
const resourceSpan = {
|
||||
resource: {
|
||||
attributes: [
|
||||
{
|
||||
key: "service.name",
|
||||
value: { stringValue: "test-service" },
|
||||
},
|
||||
],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: "langfuse-sdk",
|
||||
version: "2.0.0",
|
||||
attributes: [
|
||||
{
|
||||
key: "public_key",
|
||||
value: { stringValue: publicKey },
|
||||
},
|
||||
],
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: Buffer.from(traceId, "hex").toJSON(),
|
||||
spanId: Buffer.from(spanId, "hex").toJSON(),
|
||||
name: "pollution-test",
|
||||
kind: 1,
|
||||
startTimeUnixNano: { low: 1000000000, high: 0, unsigned: true },
|
||||
endTimeUnixNano: { low: 2000000000, high: 0, unsigned: true },
|
||||
attributes: [
|
||||
{
|
||||
key: "gen_ai.prompt.role",
|
||||
value: { stringValue: "user" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.prompt.content",
|
||||
value: { stringValue: "hello" },
|
||||
},
|
||||
{
|
||||
key: "gen_ai.prompt.__proto__.POLLUTED",
|
||||
value: { stringValue: "SUCCESS" },
|
||||
},
|
||||
],
|
||||
status: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await convertOtelSpanToIngestionEvent(
|
||||
resourceSpan,
|
||||
new Set([traceId]),
|
||||
publicKey,
|
||||
);
|
||||
|
||||
// Verify Object.prototype was NOT polluted
|
||||
expect(({} as any).POLLUTED).toBeUndefined();
|
||||
expect(Object.prototype.hasOwnProperty("POLLUTED" as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -445,6 +445,24 @@ describe("Blob Storage Integrations API", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject invalid Azure container names", async () => {
|
||||
const azureConfig = {
|
||||
...validBlobStorageConfig,
|
||||
projectId: testProject1Id,
|
||||
type: "AZURE_BLOB_STORAGE" as const,
|
||||
endpoint: "https://myaccount.blob.core.windows.net",
|
||||
bucketName: "Feedback N8N Bot",
|
||||
};
|
||||
|
||||
const result = await makeAPICall(
|
||||
"PUT",
|
||||
"/api/public/integrations/blob-storage",
|
||||
azureConfig,
|
||||
createBasicAuthHeader(testApiKey, testApiSecretKey),
|
||||
);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should handle export modes with dates", async () => {
|
||||
const customDateConfig = {
|
||||
...validBlobStorageConfig,
|
||||
|
||||
@@ -9,11 +9,13 @@ jest.mock("@langfuse/shared/src/server", () => {
|
||||
});
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { LLMAdapter } from "@langfuse/shared";
|
||||
import { BEDROCK_USE_DEFAULT_CREDENTIALS, LLMAdapter } from "@langfuse/shared";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { appRouter } from "@/src/server/api/root";
|
||||
import { createInnerTRPCContext } from "@/src/server/api/trpc";
|
||||
import { decrypt } from "@langfuse/shared/encryption";
|
||||
import { decrypt, encrypt } from "@langfuse/shared/encryption";
|
||||
import { AuthMethod } from "@/src/features/llm-api-key/types";
|
||||
import {
|
||||
createOrgProjectAndApiKey,
|
||||
fetchLLMCompletion,
|
||||
@@ -140,6 +142,61 @@ describe("llmApiKey.all RPC", () => {
|
||||
expect(llmApiKeys[0].displaySecretKey).toMatch(/^...[a-zA-Z0-9]{4}$/);
|
||||
});
|
||||
|
||||
it("should create a Bedrock llm api key with a Bedrock API key", async () => {
|
||||
const secret = "bedrock-api-key-1234";
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
secretKey: JSON.stringify({ apiKey: secret }),
|
||||
provider: "bedrock",
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "us-east-1" },
|
||||
});
|
||||
|
||||
const llmApiKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: {
|
||||
projectId,
|
||||
provider: "bedrock",
|
||||
},
|
||||
});
|
||||
|
||||
expect(decrypt(llmApiKey.secretKey)).toBe(
|
||||
JSON.stringify({
|
||||
apiKey: secret,
|
||||
}),
|
||||
);
|
||||
expect(llmApiKey.displaySecretKey).toBe("...1234");
|
||||
expect(llmApiKey.config).toEqual({ region: "us-east-1" });
|
||||
});
|
||||
|
||||
it("should reject creating a Bedrock key with invalid secret key JSON", async () => {
|
||||
await expect(
|
||||
caller.llmApiKey.create({
|
||||
projectId,
|
||||
secretKey: JSON.stringify({ unknownField: "value" }),
|
||||
provider: "bedrock",
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "us-east-1" },
|
||||
}),
|
||||
).rejects.toThrow("Invalid Bedrock credentials");
|
||||
});
|
||||
|
||||
it("should block creating an llm api key with a localhost base URL", async () => {
|
||||
await expect(
|
||||
caller.llmApiKey.create({
|
||||
projectId,
|
||||
secretKey: "test-secret",
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
baseURL: "http://localhost:11434/v1",
|
||||
}),
|
||||
).rejects.toThrow("Invalid base URL: Blocked hostname detected");
|
||||
});
|
||||
|
||||
it("should create and get an llm api key", async () => {
|
||||
const secret = "test-secret";
|
||||
const provider = "openai";
|
||||
@@ -187,6 +244,87 @@ describe("llmApiKey.all RPC", () => {
|
||||
expect(secretKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should derive the Bedrock auth method in llmApiKey.all without returning secrets", async () => {
|
||||
await prisma.llmApiKeys.createMany({
|
||||
data: [
|
||||
{
|
||||
projectId,
|
||||
provider: "bedrock-access",
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: encrypt(
|
||||
JSON.stringify({
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}),
|
||||
),
|
||||
displaySecretKey: "...MPLE",
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
extraHeaderKeys: [],
|
||||
config: { region: "us-east-1" },
|
||||
},
|
||||
{
|
||||
projectId,
|
||||
provider: "bedrock-api",
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: encrypt(
|
||||
JSON.stringify({
|
||||
apiKey: "bedrock-api-key-1234",
|
||||
}),
|
||||
),
|
||||
displaySecretKey: "...1234",
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
extraHeaderKeys: [],
|
||||
config: { region: "us-east-1" },
|
||||
},
|
||||
{
|
||||
projectId,
|
||||
provider: "bedrock-default",
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: encrypt(BEDROCK_USE_DEFAULT_CREDENTIALS),
|
||||
displaySecretKey: "Default AWS credentials",
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
extraHeaderKeys: [],
|
||||
config: { region: "us-east-1" },
|
||||
},
|
||||
{
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: encrypt("sk-test"),
|
||||
displaySecretKey: "...test",
|
||||
customModels: [],
|
||||
withDefaultModels: true,
|
||||
extraHeaderKeys: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { data: llmApiKeys } = await caller.llmApiKey.all({
|
||||
projectId,
|
||||
});
|
||||
|
||||
expect(
|
||||
llmApiKeys.find((key) => key.provider === "bedrock-access")?.authMethod,
|
||||
).toBe(AuthMethod.AccessKeys);
|
||||
expect(
|
||||
llmApiKeys.find((key) => key.provider === "bedrock-api")?.authMethod,
|
||||
).toBe(AuthMethod.ApiKey);
|
||||
expect(
|
||||
llmApiKeys.find((key) => key.provider === "bedrock-default")?.authMethod,
|
||||
).toBe(AuthMethod.DefaultCredentials);
|
||||
expect(
|
||||
llmApiKeys.find((key) => key.provider === "openai")?.authMethod,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
llmApiKeys.every(
|
||||
(key) => key.secretKey === undefined && key.extraHeaders === undefined,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should require llmApiKeys:create access for testing a new llm api key", async () => {
|
||||
const memberCaller = createCallerForProjectRole("MEMBER");
|
||||
|
||||
@@ -201,7 +339,7 @@ describe("llmApiKey.all RPC", () => {
|
||||
).rejects.toThrow("User does not have access to this resource or action");
|
||||
});
|
||||
|
||||
it("should require llmApiKeys:create access for testing an existing llm api key", async () => {
|
||||
it("should require llmApiKeys:update access for testing an existing llm api key", async () => {
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider: "openai",
|
||||
@@ -261,6 +399,31 @@ describe("llmApiKey.all RPC", () => {
|
||||
expect(mockFetchLLMCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should allow testing an existing connection with an unchanged localhost base URL", async () => {
|
||||
const connection = await prisma.llmApiKeys.create({
|
||||
data: {
|
||||
projectId,
|
||||
provider: "local-ollama",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: encrypt("sk-existing"),
|
||||
displaySecretKey: "...ting",
|
||||
baseURL: "http://localhost:11434/v1",
|
||||
customModels: ["llama3.1"],
|
||||
withDefaultModels: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.llmApiKey.testUpdate({
|
||||
id: connection.id,
|
||||
projectId,
|
||||
provider: "local-ollama",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should allow testUpdate without a new secret key when the base URL is unchanged", async () => {
|
||||
const existingExtraHeaders = {
|
||||
Authorization: "Bearer stored-token",
|
||||
@@ -411,6 +574,287 @@ describe("llmApiKey.all RPC", () => {
|
||||
expect(updatedKeys[0].withDefaultModels).toBe(newWithDefaultModels);
|
||||
});
|
||||
|
||||
it("should update a Bedrock Access key auth to a Bedrock API key", async () => {
|
||||
const provider = "bedrock";
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: JSON.stringify({
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}),
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "us-east-1" },
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: {
|
||||
projectId,
|
||||
provider,
|
||||
},
|
||||
});
|
||||
|
||||
await caller.llmApiKey.update({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: JSON.stringify({ apiKey: "bedrock-api-key-5678" }),
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "eu-west-1" },
|
||||
});
|
||||
|
||||
const updatedKey = await prisma.llmApiKeys.findUniqueOrThrow({
|
||||
where: { id: existingKey.id },
|
||||
});
|
||||
|
||||
expect(decrypt(updatedKey.secretKey)).toBe(
|
||||
JSON.stringify({
|
||||
apiKey: "bedrock-api-key-5678",
|
||||
}),
|
||||
);
|
||||
expect(updatedKey.displaySecretKey).toBe("...5678");
|
||||
expect(updatedKey.config).toEqual({ region: "eu-west-1" });
|
||||
});
|
||||
|
||||
it("should update a Bedrock API key auth to Access keys", async () => {
|
||||
const provider = "bedrock";
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: JSON.stringify({ apiKey: "bedrock-api-key-1234" }),
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "us-east-1" },
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: { projectId, provider },
|
||||
});
|
||||
|
||||
await caller.llmApiKey.update({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: JSON.stringify({
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}),
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "eu-west-1" },
|
||||
});
|
||||
|
||||
const updatedKey = await prisma.llmApiKeys.findUniqueOrThrow({
|
||||
where: { id: existingKey.id },
|
||||
});
|
||||
|
||||
expect(decrypt(updatedKey.secretKey)).toBe(
|
||||
JSON.stringify({
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}),
|
||||
);
|
||||
expect(updatedKey.displaySecretKey).toBe("...EKEY");
|
||||
expect(updatedKey.config).toEqual({ region: "eu-west-1" });
|
||||
});
|
||||
|
||||
it("should update a Bedrock DefaultCredentials key to explicit Access keys", async () => {
|
||||
const provider = "bedrock";
|
||||
const originalRegion = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION;
|
||||
|
||||
try {
|
||||
// Simulate self-hosted to allow default credentials
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: BEDROCK_USE_DEFAULT_CREDENTIALS,
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "us-east-1" },
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: { projectId, provider },
|
||||
});
|
||||
|
||||
expect(decrypt(existingKey.secretKey)).toBe(
|
||||
BEDROCK_USE_DEFAULT_CREDENTIALS,
|
||||
);
|
||||
expect(existingKey.displaySecretKey).toBe("Default AWS credentials");
|
||||
|
||||
await caller.llmApiKey.update({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: JSON.stringify({
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}),
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "eu-west-1" },
|
||||
});
|
||||
|
||||
const updatedKey = await prisma.llmApiKeys.findUniqueOrThrow({
|
||||
where: { id: existingKey.id },
|
||||
});
|
||||
|
||||
expect(decrypt(updatedKey.secretKey)).toBe(
|
||||
JSON.stringify({
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}),
|
||||
);
|
||||
expect(updatedKey.displaySecretKey).toBe("...EKEY");
|
||||
expect(updatedKey.config).toEqual({ region: "eu-west-1" });
|
||||
} finally {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = originalRegion;
|
||||
}
|
||||
});
|
||||
|
||||
it("should update a Bedrock DefaultCredentials key to a Bedrock API key", async () => {
|
||||
const provider = "bedrock";
|
||||
const originalRegion = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION;
|
||||
|
||||
try {
|
||||
// Simulate self-hosted to allow default credentials
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: BEDROCK_USE_DEFAULT_CREDENTIALS,
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "us-east-1" },
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: { projectId, provider },
|
||||
});
|
||||
|
||||
await caller.llmApiKey.update({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: JSON.stringify({ apiKey: "bedrock-api-key-9999" }),
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "eu-west-1" },
|
||||
});
|
||||
|
||||
const updatedKey = await prisma.llmApiKeys.findUniqueOrThrow({
|
||||
where: { id: existingKey.id },
|
||||
});
|
||||
|
||||
expect(decrypt(updatedKey.secretKey)).toBe(
|
||||
JSON.stringify({ apiKey: "bedrock-api-key-9999" }),
|
||||
);
|
||||
expect(updatedKey.displaySecretKey).toBe("...9999");
|
||||
expect(updatedKey.config).toEqual({ region: "eu-west-1" });
|
||||
} finally {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = originalRegion;
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject updating a Bedrock key back to DefaultCredentials on cloud", async () => {
|
||||
const provider = "bedrock";
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: JSON.stringify({
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}),
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "us-east-1" },
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: { projectId, provider },
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.llmApiKey.update({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: BEDROCK_USE_DEFAULT_CREDENTIALS,
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "eu-west-1" },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Default AWS credentials are only allowed for Bedrock in self-hosted deployments",
|
||||
);
|
||||
});
|
||||
|
||||
it("should update a Bedrock Access key auth back to DefaultCredentials (self-hosted)", async () => {
|
||||
const provider = "bedrock";
|
||||
const originalRegion = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION;
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: JSON.stringify({
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}),
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "us-east-1" },
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: { projectId, provider },
|
||||
});
|
||||
|
||||
try {
|
||||
// Simulate self-hosted deployment where default credentials are allowed
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
await caller.llmApiKey.update({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider,
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: BEDROCK_USE_DEFAULT_CREDENTIALS,
|
||||
customModels: ["us.anthropic.claude-3-5-sonnet-20240620-v1:0"],
|
||||
withDefaultModels: false,
|
||||
config: { region: "eu-west-1" },
|
||||
});
|
||||
} finally {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = originalRegion;
|
||||
}
|
||||
|
||||
const updatedKey = await prisma.llmApiKeys.findUniqueOrThrow({
|
||||
where: { id: existingKey.id },
|
||||
});
|
||||
|
||||
expect(decrypt(updatedKey.secretKey)).toBe(BEDROCK_USE_DEFAULT_CREDENTIALS);
|
||||
expect(updatedKey.displaySecretKey).toBe("Default AWS credentials");
|
||||
expect(updatedKey.config).toEqual({ region: "eu-west-1" });
|
||||
});
|
||||
|
||||
it("should update only the secret key", async () => {
|
||||
const secret = "test-secret";
|
||||
const provider = "openai";
|
||||
|
||||
@@ -354,6 +354,25 @@ describe("/api/public/llm-connections API Endpoints", () => {
|
||||
expect(response.body.extraHeaderKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("should reject creating a connection with a localhost baseURL", async () => {
|
||||
const response = await makeAPICall(
|
||||
"PUT",
|
||||
"/api/public/llm-connections",
|
||||
{
|
||||
provider: generateUniqueProvider("local-openai"),
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: "sk-local",
|
||||
baseURL: "http://localhost:11434/v1",
|
||||
},
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(
|
||||
"Invalid baseURL: Blocked hostname detected",
|
||||
);
|
||||
});
|
||||
|
||||
it("should update existing connection (upsert)", async () => {
|
||||
const existingProvider = generateUniqueProvider("existing-provider");
|
||||
|
||||
@@ -558,6 +577,26 @@ describe("/api/public/llm-connections API Endpoints", () => {
|
||||
expect(bedrockResponse.body.adapter).toBe(LLMAdapter.Bedrock);
|
||||
expect(bedrockResponse.body.config).toEqual({ region: "us-east-1" });
|
||||
|
||||
const bedrockApiKeyResponse = await makeZodVerifiedAPICall(
|
||||
PutLlmConnectionV1Response,
|
||||
"PUT",
|
||||
"/api/public/llm-connections",
|
||||
{
|
||||
provider: generateUniqueProvider("test-bedrock-api-key"),
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: JSON.stringify({ apiKey: "bedrock-api-key-1234" }),
|
||||
config: { region: "us-east-1" },
|
||||
},
|
||||
auth,
|
||||
201,
|
||||
);
|
||||
expect(bedrockApiKeyResponse.status).toBe(201);
|
||||
expect(bedrockApiKeyResponse.body.adapter).toBe(LLMAdapter.Bedrock);
|
||||
expect(bedrockApiKeyResponse.body.displaySecretKey).toBe("...1234");
|
||||
expect(bedrockApiKeyResponse.body.config).toEqual({
|
||||
region: "us-east-1",
|
||||
});
|
||||
|
||||
// VertexAI works with or without config
|
||||
const vertexResponse = await makeZodVerifiedAPICall(
|
||||
PutLlmConnectionV1Response,
|
||||
@@ -885,6 +924,41 @@ describe("/api/public/llm-connections API Endpoints", () => {
|
||||
expect(dbConnection?.config).toEqual({ region: "us-east-1" });
|
||||
});
|
||||
|
||||
it("should create Bedrock connection with a Bedrock API key", async () => {
|
||||
const createData = {
|
||||
provider: generateUniqueProvider("bedrock-api-key-config-test"),
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: JSON.stringify({ apiKey: "bedrock-api-key-9876" }),
|
||||
config: {
|
||||
region: "us-west-2",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await makeZodVerifiedAPICall(
|
||||
PutLlmConnectionV1Response,
|
||||
"PUT",
|
||||
"/api/public/llm-connections",
|
||||
createData,
|
||||
auth,
|
||||
201,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.displaySecretKey).toBe("...9876");
|
||||
expect(response.body.config).toEqual({ region: "us-west-2" });
|
||||
|
||||
const dbConnection = await prisma.llmApiKeys.findUnique({
|
||||
where: {
|
||||
projectId_provider: {
|
||||
projectId,
|
||||
provider: createData.provider,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbConnection?.displaySecretKey).toBe("...9876");
|
||||
});
|
||||
|
||||
it("should reject Bedrock connection without config", async () => {
|
||||
const createData = {
|
||||
provider: generateUniqueProvider("bedrock-no-config"),
|
||||
@@ -935,6 +1009,48 @@ describe("/api/public/llm-connections API Endpoints", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject Bedrock connection with default credentials sentinel on cloud", async () => {
|
||||
const createData = {
|
||||
provider: generateUniqueProvider("bedrock-default-creds"),
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: "__BEDROCK_DEFAULT_CREDENTIALS__",
|
||||
config: { region: "us-east-1" },
|
||||
};
|
||||
|
||||
const response = await makeAPICall(
|
||||
"PUT",
|
||||
"/api/public/llm-connections",
|
||||
createData,
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(JSON.stringify(response.body)).toContain(
|
||||
"Default AWS credentials are only allowed for Bedrock in self-hosted deployments",
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject Bedrock connection with invalid credential JSON", async () => {
|
||||
const createData = {
|
||||
provider: generateUniqueProvider("bedrock-invalid-creds"),
|
||||
adapter: LLMAdapter.Bedrock,
|
||||
secretKey: JSON.stringify({ unknownField: "value" }),
|
||||
config: { region: "us-east-1" },
|
||||
};
|
||||
|
||||
const response = await makeAPICall(
|
||||
"PUT",
|
||||
"/api/public/llm-connections",
|
||||
createData,
|
||||
auth,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(JSON.stringify(response.body)).toContain(
|
||||
"Invalid Bedrock credentials",
|
||||
);
|
||||
});
|
||||
|
||||
it("should create VertexAI connection with location config", async () => {
|
||||
const createData = {
|
||||
provider: generateUniqueProvider("vertexai-config-test"),
|
||||
|
||||
@@ -39,6 +39,34 @@ describe("Clickhouse Events Repository Test", () => {
|
||||
});
|
||||
|
||||
maybe("getObservationsWithModelDataFromEventsTable", () => {
|
||||
it("should return trace tags for events table observations", async () => {
|
||||
const traceId = randomUUID();
|
||||
const observationId = randomUUID();
|
||||
|
||||
await createEventsCh([
|
||||
createEvent({
|
||||
id: observationId,
|
||||
span_id: observationId,
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
type: "SPAN",
|
||||
name: "tagged-event",
|
||||
tags: ["chat", "prod"],
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await getObservationsWithModelDataFromEventsTable({
|
||||
projectId,
|
||||
filter: [idFilter(observationId)],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const observation = result.find((o) => o.id === observationId);
|
||||
expect(observation).toBeDefined();
|
||||
expect(observation?.traceTags).toEqual(["chat", "prod"]);
|
||||
});
|
||||
|
||||
it("should return observations with model data", async () => {
|
||||
const traceId = randomUUID();
|
||||
const generationId = randomUUID();
|
||||
@@ -1219,6 +1247,37 @@ describe("Clickhouse Events Repository Test", () => {
|
||||
});
|
||||
|
||||
maybe("getObservationByIdFromEventsTable", () => {
|
||||
it("should return trace-level fields for event observations", async () => {
|
||||
const traceId = randomUUID();
|
||||
const spanId = randomUUID();
|
||||
|
||||
await createEventsCh([
|
||||
createEvent({
|
||||
id: spanId,
|
||||
span_id: spanId,
|
||||
project_id: projectId,
|
||||
trace_id: traceId,
|
||||
type: "SPAN",
|
||||
name: "test-trace-fields-byid",
|
||||
trace_name: "trace-with-tags",
|
||||
tags: ["chat", "prod"],
|
||||
user_id: "user-123",
|
||||
session_id: "session-123",
|
||||
}),
|
||||
]);
|
||||
|
||||
const observation = await getObservationByIdFromEventsTable({
|
||||
id: spanId,
|
||||
projectId,
|
||||
});
|
||||
|
||||
expect(observation).toBeDefined();
|
||||
expect(observation?.traceName).toBe("trace-with-tags");
|
||||
expect(observation?.traceTags).toEqual(["chat", "prod"]);
|
||||
expect(observation?.userId).toBe("user-123");
|
||||
expect(observation?.sessionId).toBe("session-123");
|
||||
});
|
||||
|
||||
it("should return observation by id with input and output", async () => {
|
||||
const traceId = randomUUID();
|
||||
const generationId = randomUUID();
|
||||
|
||||
@@ -333,7 +333,7 @@ describe("Clickhouse Experiment Repository Test", () => {
|
||||
const now = new Date().getTime();
|
||||
|
||||
// Trace 1: Multiple events with timing data to test latency calculation
|
||||
// Latency should be: earliest start_time to latest end_time
|
||||
// Latency is calculated from ROOT SPAN only (span_id = experiment_item_root_span_id)
|
||||
const trace1Id = randomUUID();
|
||||
const rootSpanId = randomUUID();
|
||||
const event1 = createEvent({
|
||||
@@ -351,8 +351,8 @@ describe("Clickhouse Experiment Repository Test", () => {
|
||||
experiment_item_id: randomUUID(),
|
||||
experiment_item_version: null,
|
||||
experiment_item_root_span_id: rootSpanId,
|
||||
start_time: (now - 3500) * 1000, // Earliest start: now - 3500ms (convert to microseconds)
|
||||
end_time: (now - 2500) * 1000, // End: now - 2500ms (convert to microseconds)
|
||||
start_time: (now - 3500) * 1000, // Root span start (convert to microseconds)
|
||||
end_time: (now - 2500) * 1000, // Root span end: latency = 1000ms (convert to microseconds)
|
||||
});
|
||||
|
||||
const childSpan1Id = randomUUID();
|
||||
@@ -394,7 +394,7 @@ describe("Clickhouse Experiment Repository Test", () => {
|
||||
experiment_item_version: null,
|
||||
experiment_item_root_span_id: event1.experiment_item_root_span_id,
|
||||
start_time: (now - 3000) * 1000,
|
||||
end_time: (now - 1500) * 1000, // Latest end: now - 1500ms (convert to microseconds)
|
||||
end_time: (now - 1500) * 1000, // Child spans are NOT included in latency calculation
|
||||
});
|
||||
|
||||
// Trace 2: Single event with known latency (1000ms)
|
||||
@@ -415,8 +415,8 @@ describe("Clickhouse Experiment Repository Test", () => {
|
||||
experiment_item_id: randomUUID(),
|
||||
experiment_item_version: null,
|
||||
experiment_item_root_span_id: rootSpan2Id,
|
||||
start_time: (now - 2500) * 1000, // Start: now - 2500ms (convert to microseconds)
|
||||
end_time: (now - 1500) * 1000, // End: now - 1500ms (latency = 1000ms, convert to microseconds)
|
||||
start_time: (now - 2500) * 1000, // Root span start (convert to microseconds)
|
||||
end_time: (now - 1500) * 1000, // Root span end: latency = 1000ms (convert to microseconds)
|
||||
});
|
||||
|
||||
await createEventsCh([event1, event2, event3, event4]);
|
||||
@@ -433,7 +433,8 @@ describe("Clickhouse Experiment Repository Test", () => {
|
||||
expect(metric.latencyAvg).toBeDefined();
|
||||
expect(typeof metric.latencyAvg).toBe("number");
|
||||
|
||||
expect(metric.latencyAvg).toBeCloseTo(1500, -1); // Within 10ms tolerance
|
||||
// Latency avg = (1000ms + 1000ms) / 2 = 1000ms (only root spans count)
|
||||
expect(metric.latencyAvg).toBeCloseTo(1000, -1); // Within 10ms tolerance
|
||||
});
|
||||
|
||||
it("should handle cost calculations correctly", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createTrace,
|
||||
createSessionScore,
|
||||
getScoresByIds,
|
||||
getScoreById,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import {
|
||||
createObservationsCh,
|
||||
@@ -1304,4 +1305,121 @@ describe("/api/public/scores API Endpoint", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bearer auth (public key only)", () => {
|
||||
it("should create a score via POST /api/public/scores with Bearer public key", async () => {
|
||||
const { projectId, publicKey } = await createOrgProjectAndApiKey();
|
||||
const traceId = v4();
|
||||
const trace = createTrace({ id: traceId, project_id: projectId });
|
||||
await createTracesCh([trace]);
|
||||
|
||||
const scoreId = v4();
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
{
|
||||
id: scoreId,
|
||||
traceId,
|
||||
name: "feedback",
|
||||
value: 1,
|
||||
},
|
||||
`Bearer ${publicKey}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty("id", scoreId);
|
||||
|
||||
await waitForExpect(async () => {
|
||||
const score = await getScoreById({ projectId, scoreId });
|
||||
expect(score).toBeDefined();
|
||||
expect(score!.id).toBe(scoreId);
|
||||
expect(score!.traceId).toBe(traceId);
|
||||
expect(score!.name).toBe("feedback");
|
||||
expect(score!.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject GET /api/public/scores with Bearer public key", async () => {
|
||||
const { publicKey } = await createOrgProjectAndApiKey();
|
||||
|
||||
const response = await makeAPICall(
|
||||
"GET",
|
||||
"/api/public/scores",
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should reject GET /api/public/scores/:scoreId with Bearer public key", async () => {
|
||||
const { publicKey } = await createOrgProjectAndApiKey();
|
||||
|
||||
const response = await makeAPICall(
|
||||
"GET",
|
||||
`/api/public/scores/${v4()}`,
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should reject DELETE /api/public/scores/:scoreId with Bearer public key", async () => {
|
||||
const { publicKey } = await createOrgProjectAndApiKey();
|
||||
|
||||
const response = await makeAPICall(
|
||||
"DELETE",
|
||||
`/api/public/scores/${v4()}`,
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should reject POST /api/public/scores with invalid Bearer token", async () => {
|
||||
const response = await makeAPICall(
|
||||
"POST",
|
||||
"/api/public/scores",
|
||||
{
|
||||
traceId: v4(),
|
||||
name: "feedback",
|
||||
value: 1,
|
||||
},
|
||||
`Bearer pk-invalid-key-that-does-not-exist`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should reject Bearer public key on non-scores endpoints", async () => {
|
||||
const { publicKey } = await createOrgProjectAndApiKey();
|
||||
|
||||
const [tracesRes, observationsRes, sessionsRes] = await Promise.all([
|
||||
makeAPICall(
|
||||
"GET",
|
||||
"/api/public/traces",
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
),
|
||||
makeAPICall(
|
||||
"GET",
|
||||
"/api/public/observations",
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
),
|
||||
makeAPICall(
|
||||
"GET",
|
||||
"/api/public/sessions",
|
||||
undefined,
|
||||
`Bearer ${publicKey}`,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(tracesRes.status).toBe(401);
|
||||
expect(observationsRes.status).toBe(401);
|
||||
expect(sessionsRes.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { signupSchema } from "@/src/features/auth/lib/signupSchema";
|
||||
|
||||
describe("signupSchema name validation", () => {
|
||||
const validBaseInput = {
|
||||
email: "test@example.com",
|
||||
password: "P@ssw0rd!",
|
||||
};
|
||||
|
||||
it("accepts names with accented letters", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "André",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts names with hyphens", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "Smith-Jones",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts names with apostrophes", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "O'Brien",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts names with periods", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "Dr. Smith",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects names longer than 100 characters", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "a".repeat(101),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts names with smart/curly apostrophes (U+2019)", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "O\u2019Brien",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe("O'Brien");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts names with left single quotation mark (U+2018)", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "O\u2018Brien",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe("O'Brien");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects punctuation-only names", () => {
|
||||
for (const name of ["---", "...", "'''"]) {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects whitespace-only names", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: " ",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects names with disallowed punctuation", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "André!",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects names with a leading combining mark", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "\u0301André",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects names consisting only of combining marks", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "\u0301\u0302\u0303",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts NFD-decomposed names after NFC normalization", () => {
|
||||
// "é" decomposed as e + combining acute accent
|
||||
const result = signupSchema.safeParse({
|
||||
...validBaseInput,
|
||||
name: "Andre\u0301",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
// NFC normalization should merge the combining mark
|
||||
expect(result.data.name).toBe("André");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ describe("Slack Integration", () => {
|
||||
getWebClientForProject: jest.fn(),
|
||||
sendMessage: jest.fn(),
|
||||
getChannels: jest.fn(),
|
||||
getChannelInfo: jest.fn(),
|
||||
validateClient: jest.fn(),
|
||||
deleteIntegration: jest.fn(),
|
||||
};
|
||||
@@ -196,7 +197,10 @@ describe("Slack Integration", () => {
|
||||
},
|
||||
];
|
||||
|
||||
mockSlackService.getChannels.mockResolvedValue(mockChannels);
|
||||
mockSlackService.getChannels.mockResolvedValue({
|
||||
channels: mockChannels,
|
||||
hasPrivateChannelAccess: true,
|
||||
});
|
||||
|
||||
const { caller, project } = await prepare();
|
||||
|
||||
@@ -217,6 +221,7 @@ describe("Slack Integration", () => {
|
||||
|
||||
expect(result).toMatchObject({
|
||||
channels: mockChannels,
|
||||
hasPrivateChannelAccess: true,
|
||||
teamId: "T123456",
|
||||
teamName: "Test Team",
|
||||
});
|
||||
@@ -306,6 +311,71 @@ describe("Slack Integration", () => {
|
||||
expect(JSON.stringify(result)).not.toContain("xoxb-test-token");
|
||||
});
|
||||
|
||||
it("should resolve channel info for manually-typed channel names", async () => {
|
||||
const mockClient = { auth: { test: jest.fn() } };
|
||||
mockSlackService.getWebClientForProject.mockResolvedValue(mockClient);
|
||||
mockSlackService.sendMessage.mockResolvedValue({
|
||||
messageTs: "1234567890.123456",
|
||||
channel: "C999888",
|
||||
});
|
||||
mockSlackService.getChannelInfo.mockResolvedValue({
|
||||
id: "C999888",
|
||||
name: "general",
|
||||
isPrivate: false,
|
||||
});
|
||||
|
||||
const { caller, project } = await prepare();
|
||||
|
||||
await prisma.slackIntegration.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
teamId: "T123456",
|
||||
teamName: "Test Team",
|
||||
botToken: encrypt("xoxb-test-token"),
|
||||
botUserId: "U123456",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.slack.sendTestMessage({
|
||||
projectId: project.id,
|
||||
channelId: "#general",
|
||||
channelName: "general",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
channel: "C999888",
|
||||
channelInfo: {
|
||||
id: "C999888",
|
||||
name: "general",
|
||||
isPrivate: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockSlackService.getChannelInfo).toHaveBeenCalledWith(
|
||||
mockClient,
|
||||
"C999888",
|
||||
);
|
||||
|
||||
// Verify audit log records the resolved channel ID, not the #-prefixed input
|
||||
const auditLogEntry = await prisma.auditLog.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
resourceType: "slackIntegration",
|
||||
action: "create",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
expect(auditLogEntry).toBeDefined();
|
||||
const afterData = auditLogEntry?.after
|
||||
? JSON.parse(auditLogEntry.after)
|
||||
: null;
|
||||
expect(afterData).toMatchObject({
|
||||
channelId: "C999888",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create audit log entry", async () => {
|
||||
const mockClient = { auth: { test: jest.fn() } };
|
||||
mockSlackService.getWebClientForProject.mockResolvedValue(mockClient);
|
||||
@@ -501,9 +571,12 @@ describe("Slack Integration", () => {
|
||||
|
||||
it("should NEVER expose raw bot tokens in any API response", async () => {
|
||||
mockSlackService.validateClient.mockResolvedValue(true);
|
||||
mockSlackService.getChannels.mockResolvedValue([
|
||||
{ id: "C123456", name: "general", isPrivate: false, isMember: true },
|
||||
]);
|
||||
mockSlackService.getChannels.mockResolvedValue({
|
||||
channels: [
|
||||
{ id: "C123456", name: "general", isPrivate: false, isMember: true },
|
||||
],
|
||||
hasPrivateChannelAccess: true,
|
||||
});
|
||||
mockSlackService.sendMessage.mockResolvedValue({
|
||||
messageTs: "1234567890.123456",
|
||||
channel: "C123456",
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("shouldUseWidgetSSE", () => {
|
||||
it("should enable SSE on the v4 beta v2 path", () => {
|
||||
expect(
|
||||
shouldUseWidgetSSE({
|
||||
isV4BetaEnabled: true,
|
||||
isV4Enabled: true,
|
||||
version: "v2",
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -26,7 +26,7 @@ describe("shouldUseWidgetSSE", () => {
|
||||
it("should disable SSE when v4 beta is off", () => {
|
||||
expect(
|
||||
shouldUseWidgetSSE({
|
||||
isV4BetaEnabled: false,
|
||||
isV4Enabled: false,
|
||||
version: "v2",
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -35,7 +35,7 @@ describe("shouldUseWidgetSSE", () => {
|
||||
it("should disable SSE for non-v4 query versions", () => {
|
||||
expect(
|
||||
shouldUseWidgetSSE({
|
||||
isV4BetaEnabled: true,
|
||||
isV4Enabled: true,
|
||||
version: "v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
@@ -142,6 +142,7 @@ export function useMessageSearch() {
|
||||
openSearch: controller.openSearch,
|
||||
closeSearch: controller.closeSearch,
|
||||
setQueryInput: controller.setQueryInput,
|
||||
blurQueryInput: controller.blurQueryInput,
|
||||
nextMatch: controller.nextMatch,
|
||||
previousMatch: controller.previousMatch,
|
||||
};
|
||||
|
||||
@@ -123,4 +123,60 @@ describe("message search controller", () => {
|
||||
expect.objectContaining({ from: 5, to: 6 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves leading and trailing whitespace in literal queries", () => {
|
||||
const controller = createMessageSearchController(["page-1"]);
|
||||
|
||||
controller.registerPageMessages("page-1", [
|
||||
{
|
||||
id: "message-1",
|
||||
type: ChatMessageType.System,
|
||||
role: ChatMessageRole.System,
|
||||
content: " foo foo ",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(commitQuery(controller, " foo")).toEqual([
|
||||
expect.objectContaining({ from: 0, to: 4 }),
|
||||
expect.objectContaining({ from: 4, to: 8 }),
|
||||
]);
|
||||
expect(controller.getSnapshot().query).toBe(" foo");
|
||||
|
||||
controller.setQueryInput("foo ");
|
||||
controller.nextMatch();
|
||||
|
||||
expect(controller.getSnapshot().query).toBe("foo ");
|
||||
expect(controller.getSnapshot().matches).toEqual([
|
||||
expect.objectContaining({ from: 1, to: 5 }),
|
||||
expect.objectContaining({ from: 5, to: 9 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears whitespace-only input on blur", () => {
|
||||
const controller = createMessageSearchController(["page-1"]);
|
||||
|
||||
controller.registerPageMessages("page-1", [
|
||||
{
|
||||
id: "message-1",
|
||||
type: ChatMessageType.System,
|
||||
role: ChatMessageRole.System,
|
||||
content: "a b",
|
||||
},
|
||||
]);
|
||||
|
||||
controller.setQueryInput(" ");
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(controller.getSnapshot().queryInput).toBe(" ");
|
||||
expect(controller.getSnapshot().query).toBe(" ");
|
||||
expect(controller.getSnapshot().matches).toEqual([
|
||||
expect.objectContaining({ from: 1, to: 4 }),
|
||||
]);
|
||||
|
||||
controller.blurQueryInput();
|
||||
|
||||
expect(controller.getSnapshot().queryInput).toBe("");
|
||||
expect(controller.getSnapshot().query).toBe("");
|
||||
expect(controller.getSnapshot().matches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ export type MessageSearchController = {
|
||||
openSearch: () => void;
|
||||
closeSearch: () => void;
|
||||
setQueryInput: (value: string) => void;
|
||||
blurQueryInput: () => void;
|
||||
nextMatch: () => void;
|
||||
previousMatch: () => void;
|
||||
setPageIds: (pageIds: string[]) => void;
|
||||
@@ -376,7 +377,7 @@ export function createMessageSearchController(
|
||||
|
||||
clearPendingQueryTimeout();
|
||||
|
||||
const queryChanged = commitSearchQuery(state.queryInput.trim());
|
||||
const queryChanged = commitSearchQuery(state.queryInput);
|
||||
if (queryChanged) {
|
||||
emit();
|
||||
}
|
||||
@@ -465,8 +466,7 @@ export function createMessageSearchController(
|
||||
state.queryInput = value;
|
||||
clearPendingQueryTimeout();
|
||||
|
||||
const nextSearchQuery = value.trim();
|
||||
if (nextSearchQuery === "") {
|
||||
if (value === "") {
|
||||
commitSearchQuery("");
|
||||
emit();
|
||||
return;
|
||||
@@ -474,20 +474,38 @@ export function createMessageSearchController(
|
||||
|
||||
emit();
|
||||
|
||||
if (nextSearchQuery === state.searchQuery) {
|
||||
if (value === state.searchQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingQueryTimeout = window.setTimeout(() => {
|
||||
pendingQueryTimeout = null;
|
||||
|
||||
const queryChanged = commitSearchQuery(nextSearchQuery);
|
||||
const queryChanged = commitSearchQuery(value);
|
||||
if (queryChanged) {
|
||||
emit();
|
||||
}
|
||||
}, SEARCH_INPUT_DEBOUNCE_MS);
|
||||
},
|
||||
|
||||
blurQueryInput() {
|
||||
if (state.queryInput.trim() !== "") {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingQueryTimeout();
|
||||
|
||||
const queryChanged = commitSearchQuery("");
|
||||
const inputChanged = state.queryInput !== "";
|
||||
if (inputChanged) {
|
||||
state.queryInput = "";
|
||||
}
|
||||
|
||||
if (queryChanged || inputChanged) {
|
||||
emit();
|
||||
}
|
||||
},
|
||||
|
||||
nextMatch() {
|
||||
moveActiveMatch(1);
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ export function MessageSearchToolbar({ className }: { className?: string }) {
|
||||
openSearch,
|
||||
closeSearch,
|
||||
setQueryInput,
|
||||
blurQueryInput,
|
||||
nextMatch,
|
||||
previousMatch,
|
||||
} = useMessageSearch();
|
||||
@@ -71,6 +72,7 @@ export function MessageSearchToolbar({ className }: { className?: string }) {
|
||||
ref={inputRef}
|
||||
value={queryInput}
|
||||
onChange={(event) => setQueryInput(event.target.value)}
|
||||
onBlur={blurQueryInput}
|
||||
placeholder="Find in messages"
|
||||
className="h-6 min-w-40 border-0 px-1 text-xs shadow-none focus-visible:ring-0 sm:min-w-56"
|
||||
onKeyDown={(event) => {
|
||||
|
||||
@@ -6,7 +6,12 @@ import CodeMirror, {
|
||||
ViewPlugin,
|
||||
type ViewUpdate,
|
||||
} from "@uiw/react-codemirror";
|
||||
import { RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
|
||||
import {
|
||||
EditorState,
|
||||
RangeSetBuilder,
|
||||
StateEffect,
|
||||
StateField,
|
||||
} from "@codemirror/state";
|
||||
import { SearchQuery, search, setSearchQuery } from "@codemirror/search";
|
||||
import { json, jsonParseLinter } from "@codemirror/lang-json";
|
||||
import { linter, type Diagnostic } from "@codemirror/lint";
|
||||
@@ -428,6 +433,10 @@ export function CodeMirrorEditor({
|
||||
}}
|
||||
lang={mode === "json" ? "json" : undefined}
|
||||
extensions={[
|
||||
// Block document changes (including paste) when not editable; the
|
||||
// `editable` DOM facet alone does not always prevent paste (see CM6
|
||||
// EditorState.readOnly vs EditorView.editable).
|
||||
...(!editable ? [EditorState.readOnly.of(true)] : []),
|
||||
searchHighlightingSupport,
|
||||
search(),
|
||||
// RTL/bidi support - must be early for proper line decoration
|
||||
|
||||
@@ -8,15 +8,10 @@ import type { NavigationFilterContext } from "./navigationFilters.types";
|
||||
import { hasProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { hasOrganizationAccess } from "@/src/features/rbac/utils/checkOrganizationAccess";
|
||||
import type { User } from "next-auth";
|
||||
import type { Flag } from "@/src/features/feature-flags/types";
|
||||
import { getExperimentsAccess } from "@/src/features/experiments/utils/experimentsAccess";
|
||||
|
||||
/** Organization type from user session (can be null when not in project/org context) */
|
||||
type Organization = User["organizations"][number] | null | undefined;
|
||||
|
||||
// Admin-only flags that don't respect experimental features
|
||||
const adminOnlyFlags: Flag[] = ["experimentsV4Enabled"];
|
||||
|
||||
/**
|
||||
* Individual filter functions - each handles one concern
|
||||
* Exported for testing and composition
|
||||
@@ -73,32 +68,20 @@ export const filters = {
|
||||
* - Experimental features enabled
|
||||
* - User is cloud admin
|
||||
* - User has specific feature flag
|
||||
* - For v4Beta: show to all cloud users and keep it visible for opted-in users outside cloud
|
||||
*/
|
||||
featureFlags: (route: Route, ctx: NavigationFilterContext): Route | null => {
|
||||
if (route.featureFlag === undefined) return route;
|
||||
|
||||
if (route.featureFlag && adminOnlyFlags.includes(route.featureFlag)) {
|
||||
const access = getExperimentsAccess({
|
||||
isLangfuseCloud: ctx.isLangfuseCloud,
|
||||
isV4BetaEnabled: ctx.session?.user?.v4BetaEnabled === true,
|
||||
isAdmin: ctx.cloudAdmin,
|
||||
isFeatureEnabledOnUser:
|
||||
ctx.session?.user?.featureFlags?.[route.featureFlag] === true,
|
||||
});
|
||||
|
||||
return access.isEnabled ? route : null;
|
||||
if (route.featureFlag === "experimentsV4Enabled") {
|
||||
return ctx.isLangfuseCloud && ctx.session?.user?.v4BetaEnabled === true
|
||||
? route
|
||||
: null;
|
||||
}
|
||||
|
||||
if (route.featureFlag === "v4BetaToggleVisible") {
|
||||
const hasOptedIn = ctx.session?.user?.v4BetaEnabled === true;
|
||||
const canToggleV4 = ctx.session?.user?.canToggleV4 === true;
|
||||
|
||||
return ctx.isLangfuseCloud ||
|
||||
ctx.enableExperimentalFeatures ||
|
||||
ctx.cloudAdmin ||
|
||||
hasOptedIn
|
||||
? route
|
||||
: null;
|
||||
return canToggleV4 && ctx.isLangfuseCloud ? route : null;
|
||||
}
|
||||
|
||||
const hasFlag =
|
||||
|
||||
@@ -37,25 +37,21 @@ const PaymentBanner = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
const V4BetaEnabledBanner = dynamic(
|
||||
const V4EnabledBanner = dynamic(
|
||||
() =>
|
||||
import("@/src/features/events/components/V4BetaEnabledBanner").then(
|
||||
(mod) => ({
|
||||
default: mod.V4BetaEnabledBanner,
|
||||
}),
|
||||
),
|
||||
import("@/src/features/events/components/V4EnabledBanner").then((mod) => ({
|
||||
default: mod.V4EnabledBanner,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const V4BetaPromoBanner = dynamic(
|
||||
const V4PromoBanner = dynamic(
|
||||
() =>
|
||||
import("@/src/features/events/components/V4BetaPromoBanner").then(
|
||||
(mod) => ({
|
||||
default: mod.V4BetaPromoBanner,
|
||||
}),
|
||||
),
|
||||
import("@/src/features/events/components/V4PromoBanner").then((mod) => ({
|
||||
default: mod.V4PromoBanner,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
@@ -140,8 +136,8 @@ export function AuthenticatedLayout({
|
||||
<SidebarProvider>
|
||||
<div className="flex h-dvh w-full flex-col">
|
||||
<PaymentBanner />
|
||||
<V4BetaEnabledBanner />
|
||||
<V4BetaPromoBanner />
|
||||
<V4EnabledBanner />
|
||||
<V4PromoBanner />
|
||||
<div className="pt-banner-offset flex min-h-0 flex-1">
|
||||
<AppSidebar
|
||||
navItems={navigation.mainNavigation}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { type User } from "next-auth";
|
||||
import { type OrganizationScope } from "@/src/features/rbac/constants/organizationAccessRights";
|
||||
import { SupportButton } from "@/src/components/nav/support-button";
|
||||
import { BookACallButton } from "@/src/components/nav/book-a-call-button";
|
||||
import { V4BetaSidebarToggle } from "@/src/features/events/components/V4BetaSidebarToggle";
|
||||
import { V4SidebarToggle } from "@/src/features/events/components/V4SidebarToggle";
|
||||
import { SidebarMenuButton } from "@/src/components/ui/sidebar";
|
||||
import { useCommandMenu } from "@/src/features/command-k-menu/CommandMenuProvider";
|
||||
import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture";
|
||||
@@ -209,7 +209,7 @@ export const ROUTES: Route[] = [
|
||||
pathname: "",
|
||||
section: RouteSection.Secondary,
|
||||
featureFlag: "v4BetaToggleVisible",
|
||||
menuNode: <V4BetaSidebarToggle />,
|
||||
menuNode: <V4SidebarToggle />,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { SplashScreen } from "@/src/components/ui/splash-screen";
|
||||
import { TracingSetup } from "@/src/pages/project/[projectId]/traces/setup";
|
||||
import { TracesSetupOnboardingCard } from "@/src/features/setup/components/TracesSetupOnboardingCard";
|
||||
|
||||
interface TracesOnboardingProps {
|
||||
projectId: string;
|
||||
@@ -8,15 +7,8 @@ interface TracesOnboardingProps {
|
||||
|
||||
export function TracesOnboarding({ projectId }: TracesOnboardingProps) {
|
||||
return (
|
||||
<SplashScreen
|
||||
title="You don't have any traces yet"
|
||||
description="Traces show you how your LLM calls behave in your application: what they cost, how they perform, and where things go wrong. It's the first step towards improving the behavior of your app."
|
||||
videoSrc="https://static.langfuse.com/prod-assets/onboarding/tracing-overview-v1.mp4"
|
||||
>
|
||||
<div className="mt-8">
|
||||
<h3 className="mb-8 text-2xl font-semibold">Get started</h3>
|
||||
<TracingSetup projectId={projectId} hasTracingConfigured={false} />
|
||||
</div>
|
||||
</SplashScreen>
|
||||
<div className="space-y-10">
|
||||
<TracesSetupOnboardingCard projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user