Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
dd632fea9e | ||
|
|
7527bb0d84 | ||
|
|
8cc4a5537f | ||
|
|
0a6d3f108a | ||
|
|
1bf83313e3 | ||
|
|
9c3a715d77 | ||
|
|
0cf2a33473 | ||
|
|
51554eb066 | ||
|
|
cbc21bb9cc | ||
|
|
8e30694214 | ||
|
|
0d20d9de2b |
@@ -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
|
||||
|
||||
@@ -145,6 +145,11 @@ 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"
|
||||
|
||||
@@ -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: 5
|
||||
versioning-strategy: "increase"
|
||||
commit-message:
|
||||
prefix: chore
|
||||
|
||||
+193
-39
@@ -631,25 +631,45 @@ jobs:
|
||||
run: |
|
||||
echo "Job results: ${{ 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"
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
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:
|
||||
- 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
|
||||
@@ -665,17 +685,15 @@ jobs:
|
||||
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
|
||||
- name: Extract metadata (labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175 # v4
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/langfuse/langfuse # GitHub
|
||||
langfuse/langfuse # Docker Hub
|
||||
ghcr.io/langfuse/${{ matrix.image_name }}
|
||||
langfuse/${{ matrix.image_name }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
@@ -686,24 +704,90 @@ 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)
|
||||
- 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: ./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
|
||||
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
|
||||
run: |
|
||||
if [ -z '${{ steps.build-ghcr.outputs.digest }}' ] || [ -z '${{ steps.build-dockerhub.outputs.digest }}' ]; then
|
||||
echo "Missing registry digest output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$RUNNER_TEMP/digests/ghcr" "$RUNNER_TEMP/digests/dockerhub"
|
||||
printf '%s\n' '${{ steps.build-ghcr.outputs.digest }}' > "$RUNNER_TEMP/digests/ghcr/${{ matrix.platform_tag }}.txt"
|
||||
printf '%s\n' '${{ steps.build-dockerhub.outputs.digest }}' > "$RUNNER_TEMP/digests/dockerhub/${{ matrix.platform_tag }}.txt"
|
||||
- name: Upload release digests
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
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:
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@5241b2e9423e8b1fa37ed6050ecb62d0fb9a4e38 # v1
|
||||
- name: Download release digests
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
|
||||
with:
|
||||
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@818d4b7b91585d195f67373fd9cb0332e31a7175 # v4
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/langfuse/langfuse-worker # GitHub
|
||||
langfuse/langfuse-worker # Docker Hub
|
||||
images: ghcr.io/langfuse/${{ matrix.image_name }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
@@ -714,17 +798,87 @@ 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
|
||||
- name: Extract metadata (tags) for Docker Hub
|
||||
id: meta-dockerhub
|
||||
uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175 # v4
|
||||
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' || '' }}
|
||||
images: 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: Publish multi-platform manifest to GitHub Container Registry
|
||||
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/${{ matrix.image_name }}@$digest")
|
||||
done
|
||||
|
||||
if [ "${#ghcr_sources[@]}" -lt 2 ]; then
|
||||
echo "Expected amd64 and arm64 GHCR digests for ${{ matrix.component }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker buildx imagetools create "${ghcr_tags[@]}" "${ghcr_sources[@]}"
|
||||
- name: Publish multi-platform manifest to Docker Hub
|
||||
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/${{ matrix.image_name }}@$digest")
|
||||
done
|
||||
|
||||
if [ "${#dockerhub_sources[@]}" -lt 2 ]; then
|
||||
echo "Expected amd64 and arm64 Docker Hub digests for ${{ matrix.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"
|
||||
|
||||
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
|
||||
uses: ravsamhq/notify-slack-action@be814b201e233b2dc673608aa46e5447c8ab13f2 # v2
|
||||
if: always()
|
||||
|
||||
+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.166.0",
|
||||
"version": "3.167.4",
|
||||
"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",
|
||||
|
||||
@@ -97,6 +97,15 @@ the same PR.
|
||||
2. Update ClickHouse query/mapping logic in `src/server/clickhouse/*` and
|
||||
related repositories.
|
||||
3. Validate ingestion/read path impact in both `web` and `worker`.
|
||||
4. If the change affects columns, types, or nullability of tables read by blob
|
||||
storage export queries (`getTracesForBlobStorageExport`,
|
||||
`getObservationsForBlobStorageExport`, `getScoresForBlobStorageExport`,
|
||||
`getEventsForBlobStorageExport`, or the EventsQueryBuilder `export` field
|
||||
set), fetch the latest published docs and check for discrepancies:
|
||||
- https://langfuse.com/docs/api-and-data-platform/features/export-to-blob-storage
|
||||
- https://langfuse.com/docs/api-and-data-platform/features/blob-storage-export-fields
|
||||
Surface any mismatches in field names, types, nullability, or filter
|
||||
descriptions so they can be addressed in the docs repo.
|
||||
|
||||
### Queue payload contract change
|
||||
|
||||
@@ -130,3 +139,8 @@ the same PR.
|
||||
- Do not hand-edit generated artifacts under `prisma/generated/*` or `dist/*`.
|
||||
- Avoid exposing server-only modules through `src/index.ts` if they must remain
|
||||
frontend-safe.
|
||||
- Changes to domain constants consumed by blob storage exports (e.g.
|
||||
`LISTABLE_SCORE_TYPES` in `src/domain/scores.ts`, score data type enums)
|
||||
should be reviewed against the blob storage export field reference docs for
|
||||
consistency — fetch the latest page and surface any discrepancies:
|
||||
https://langfuse.com/docs/api-and-data-platform/features/blob-storage-export-fields
|
||||
|
||||
@@ -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,9 +108,9 @@
|
||||
"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",
|
||||
"next-auth": "^4.24.13",
|
||||
"nodemailer": "^7.0.11",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.166.0";
|
||||
export const VERSION = "v3.167.4";
|
||||
|
||||
@@ -261,6 +261,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 +290,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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -30,6 +30,7 @@ export * from "./llm/utils";
|
||||
export * from "./llm/types";
|
||||
export * from "./llm/compileChatMessages";
|
||||
export * from "./llm/testModelCall";
|
||||
export * from "./llm/baseUrlValidation";
|
||||
export * from "./llm/getInternalTracingHandler";
|
||||
export * from "./utils/DatabaseReadStream";
|
||||
export * from "./utils/transforms";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -43,7 +43,11 @@ 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";
|
||||
|
||||
@@ -453,6 +457,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 +477,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 +505,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,20 +542,37 @@ 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) {
|
||||
|
||||
@@ -4,6 +4,11 @@ import { processEventBatch } from "../ingestion/processEventBatch";
|
||||
import { logger } from "../logger";
|
||||
import { traceException } from "../instrumentation";
|
||||
|
||||
type TracedEvent = {
|
||||
type: string;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts and merges generation details from a list of processed events.
|
||||
* Handles multiple generation-create and generation-update events with the same id.
|
||||
@@ -73,6 +78,66 @@ export function extractGenerationDetails(
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareTracedEventsForIngestion(
|
||||
events: TracedEvent[],
|
||||
{ environment, prompt }: Pick<TraceSinkParams, "environment" | "prompt">,
|
||||
): TracedEvent[] {
|
||||
const blockedSpanIds = new Set<string>();
|
||||
const blockedSpanNames = [
|
||||
"RunnableLambda",
|
||||
"StructuredOutputParser",
|
||||
"StrOutputParser",
|
||||
"JsonOutputParser",
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
const eventName = event.body.name;
|
||||
|
||||
if (typeof eventName !== "string" || eventName.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
blockedSpanNames.includes(eventName) &&
|
||||
typeof event.body.id === "string"
|
||||
) {
|
||||
blockedSpanIds.add(event.body.id);
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
.filter((event) => {
|
||||
if (typeof event.body.id === "string") {
|
||||
return !blockedSpanIds.has(event.body.id);
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((event) => {
|
||||
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>;
|
||||
@@ -91,46 +156,13 @@ export function getInternalTracingHandler(traceSinkParams: TraceSinkParams): {
|
||||
traceSinkParams.targetProjectId,
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
const processedEvents = prepareTracedEventsForIngestion(
|
||||
events as TracedEvent[],
|
||||
{
|
||||
environment,
|
||||
prompt,
|
||||
},
|
||||
);
|
||||
|
||||
await processEventBatch(
|
||||
JSON.parse(JSON.stringify(processedEvents)), // stringify to emulate network event batch from network call
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
|
||||
Generated
+1515
-982
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
+467
@@ -0,0 +1,467 @@
|
||||
#!/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}"
|
||||
|
||||
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_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_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
+20
@@ -0,0 +1,20 @@
|
||||
#!/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
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/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
|
||||
+18
-6
@@ -17,7 +17,21 @@ FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS runtime-base
|
||||
# Remove build-only package managers. npm stays here because the runner stage
|
||||
# uses it to install prisma and optional dd-trace before removing it.
|
||||
RUN rm -rf /usr/local/lib/node_modules/corepack && \
|
||||
rm -f /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
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
|
||||
|
||||
@@ -122,7 +136,7 @@ ARG GID=1001
|
||||
RUN addgroup --system --gid ${GID} nodejs
|
||||
RUN adduser --system --uid ${UID} nextjs
|
||||
|
||||
RUN npm install -g --no-package-lock --no-save prisma@6.17.1
|
||||
RUN npm install -g --no-package-lock --no-save prisma@6.19.3
|
||||
|
||||
# Install dd-trace only if NEXT_PUBLIC_LANGFUSE_CLOUD_REGION is configured
|
||||
ARG NEXT_PUBLIC_LANGFUSE_CLOUD_REGION
|
||||
@@ -130,13 +144,11 @@ RUN if [ -n "$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION" ]; then \
|
||||
npm install --no-package-lock --no-save dd-trace@5.65.0; \
|
||||
fi
|
||||
|
||||
# Runtime images do not need npm once explicit runtime tools are installed.
|
||||
# npm is only used for the installs above; remove it from the final runtime image.
|
||||
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.166.0",
|
||||
"version": "3.167.4",
|
||||
"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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
jest.mock("@langfuse/shared/src/server", () => {
|
||||
const actual = jest.requireActual("@langfuse/shared/src/server");
|
||||
return {
|
||||
...actual,
|
||||
fetchLLMCompletion: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { LLMAdapter } from "@langfuse/shared";
|
||||
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 { createOrgProjectAndApiKey } from "@langfuse/shared/src/server";
|
||||
import { decrypt, encrypt } from "@langfuse/shared/encryption";
|
||||
import {
|
||||
createOrgProjectAndApiKey,
|
||||
fetchLLMCompletion,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
const mockFetchLLMCompletion = jest.mocked(fetchLLMCompletion);
|
||||
|
||||
describe("llmApiKey.all RPC", () => {
|
||||
let projectId: string;
|
||||
@@ -49,6 +62,7 @@ describe("llmApiKey.all RPC", () => {
|
||||
const setup = await createOrgProjectAndApiKey();
|
||||
projectId = setup.projectId;
|
||||
orgId = setup.orgId;
|
||||
mockFetchLLMCompletion.mockReset().mockResolvedValue({});
|
||||
|
||||
session = {
|
||||
expires: "1",
|
||||
@@ -126,6 +140,18 @@ describe("llmApiKey.all RPC", () => {
|
||||
expect(llmApiKeys[0].displaySecretKey).toMatch(/^...[a-zA-Z0-9]{4}$/);
|
||||
});
|
||||
|
||||
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";
|
||||
@@ -216,6 +242,141 @@ describe("llmApiKey.all RPC", () => {
|
||||
).rejects.toThrow("User does not have access to this resource or action");
|
||||
});
|
||||
|
||||
it("should block testUpdate when the base URL changes without a new secret key", async () => {
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: "sk-original",
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: {
|
||||
projectId,
|
||||
provider: "openai",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.llmApiKey.testUpdate({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
baseURL: "https://attacker.example.com/v1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Secret key is required when changing the base URL",
|
||||
});
|
||||
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",
|
||||
"X-Custom-Header": "stored-value",
|
||||
};
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: "sk-original",
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
extraHeaders: existingExtraHeaders,
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: {
|
||||
projectId,
|
||||
provider: "openai",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.llmApiKey.testUpdate({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1);
|
||||
const llmConnection = mockFetchLLMCompletion.mock.calls[0][0].llmConnection;
|
||||
expect(llmConnection.baseURL).toBe("https://api.openai.com/v1");
|
||||
expect(decrypt(llmConnection.secretKey)).toBe("sk-original");
|
||||
expect(JSON.parse(decrypt(llmConnection.extraHeaders))).toEqual(
|
||||
existingExtraHeaders,
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow testUpdate when the base URL changes and a new secret key is provided", async () => {
|
||||
const existingExtraHeaders = {
|
||||
Authorization: "Bearer stored-token",
|
||||
"X-Custom-Header": "stored-value",
|
||||
};
|
||||
|
||||
await caller.llmApiKey.create({
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: "sk-original",
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
extraHeaders: existingExtraHeaders,
|
||||
});
|
||||
|
||||
const existingKey = await prisma.llmApiKeys.findFirstOrThrow({
|
||||
where: {
|
||||
projectId,
|
||||
provider: "openai",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.llmApiKey.testUpdate({
|
||||
id: existingKey.id,
|
||||
projectId,
|
||||
provider: "openai",
|
||||
adapter: LLMAdapter.OpenAI,
|
||||
secretKey: "sk-rotated",
|
||||
baseURL: "https://new-endpoint.example.com/v1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1);
|
||||
const llmConnection = mockFetchLLMCompletion.mock.calls[0][0].llmConnection;
|
||||
expect(llmConnection.baseURL).toBe("https://new-endpoint.example.com/v1");
|
||||
expect(decrypt(llmConnection.secretKey)).toBe("sk-rotated");
|
||||
expect(llmConnection.extraHeaders).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create and update an llm api 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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -77,6 +77,7 @@ interface DataTableProps<TData, TValue> {
|
||||
tableName: string;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
topAlignCells?: boolean;
|
||||
cellPadding?: "compact" | "comfortable";
|
||||
}
|
||||
|
||||
export interface AsyncTableData<T> {
|
||||
@@ -165,6 +166,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
tableName,
|
||||
getRowClassName,
|
||||
topAlignCells = false,
|
||||
cellPadding = "compact",
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const rowheighttw = getRowHeightTailwindClass(rowHeight, customRowHeights);
|
||||
@@ -416,6 +418,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
|
||||
getRowClassName={getRowClassName}
|
||||
topAlignCells={topAlignCells}
|
||||
cellPadding={cellPadding}
|
||||
tableSnapshot={{
|
||||
columnVisibility,
|
||||
columnOrder,
|
||||
@@ -434,6 +437,7 @@ export function DataTable<TData extends object, TValue>({
|
||||
onRowClick={hasRowClickAction ? handleOnRowClick : undefined}
|
||||
getRowClassName={getRowClassName}
|
||||
topAlignCells={topAlignCells}
|
||||
cellPadding={cellPadding}
|
||||
/>
|
||||
)}
|
||||
</Table>
|
||||
@@ -480,6 +484,7 @@ interface TableBodyComponentProps<TData> {
|
||||
onRowClick?: (row: TData, event?: React.MouseEvent) => void;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
topAlignCells?: boolean;
|
||||
cellPadding?: "compact" | "comfortable";
|
||||
tableSnapshot?: {
|
||||
columnVisibility?: VisibilityState;
|
||||
columnOrder?: ColumnOrderState;
|
||||
@@ -533,6 +538,7 @@ function TableBodyComponent<TData>({
|
||||
onRowClick,
|
||||
getRowClassName,
|
||||
topAlignCells = false,
|
||||
cellPadding = "compact",
|
||||
}: TableBodyComponentProps<TData>) {
|
||||
return (
|
||||
<TableBody>
|
||||
@@ -562,7 +568,8 @@ function TableBodyComponent<TData>({
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
"overflow-hidden border-b px-1 text-xs first:pl-2",
|
||||
"overflow-hidden border-b text-xs first:pl-2",
|
||||
cellPadding === "comfortable" ? "p-1" : "px-1",
|
||||
isSmallRowHeight && "whitespace-nowrap",
|
||||
getPinningClasses(cell.column),
|
||||
)}
|
||||
@@ -654,6 +661,7 @@ const MemoizedTableBody = React.memo(TableBodyComponent, (prev, next) => {
|
||||
if (prev.data.isLoading !== next.data.isLoading) return false;
|
||||
if (prev.rowheighttw !== next.rowheighttw) return false;
|
||||
if (prev.rowHeight !== next.rowHeight) return false;
|
||||
if (prev.cellPadding !== next.cellPadding) return false;
|
||||
|
||||
// Then do more expensive deep equality checks
|
||||
if (
|
||||
|
||||
@@ -358,6 +358,7 @@ export default function ModelTable({ projectId }: { projectId: string }) {
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
cellPadding="comfortable"
|
||||
onRowClick={(row) => {
|
||||
router.push(`/project/${projectId}/settings/models/${row.modelId}`);
|
||||
}}
|
||||
|
||||
@@ -284,6 +284,7 @@ export function ScoreConfigsTable({ projectId }: { projectId: string }) {
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
rowHeight={rowHeight}
|
||||
cellPadding="comfortable"
|
||||
className="gap-2"
|
||||
/>
|
||||
</SettingsTableCard>
|
||||
|
||||
@@ -13,8 +13,9 @@ import { Avatar, AvatarImage } from "@/src/components/ui/avatar";
|
||||
import useColumnVisibility from "@/src/features/column-visibility/hooks/useColumnVisibility";
|
||||
import { useSidebarFilterState } from "@/src/features/filters/hooks/useSidebarFilterState";
|
||||
import {
|
||||
scoreFilterConfig,
|
||||
getScoreFilterConfig,
|
||||
SCORE_COLUMN_TO_BACKEND_KEY,
|
||||
type ScoresTableHiddenColumn,
|
||||
} from "@/src/features/filters/config/scores-config";
|
||||
import { DEFAULT_SIDEBAR_IMPLICIT_ENVIRONMENT_CONFIG } from "@/src/features/filters/constants/internal-environments";
|
||||
import { transformFiltersForBackend } from "@/src/features/filters/lib/filter-transform";
|
||||
@@ -80,6 +81,16 @@ export type ScoresTableRow = {
|
||||
executionTraceId?: string;
|
||||
};
|
||||
|
||||
export type ScoresTableProps = {
|
||||
projectId: string;
|
||||
userId?: string;
|
||||
traceId?: string;
|
||||
observationId?: string;
|
||||
hiddenColumns?: ScoresTableHiddenColumn[];
|
||||
localStorageSuffix?: string;
|
||||
disableUrlPersistence?: boolean;
|
||||
};
|
||||
|
||||
function createFilterState(
|
||||
userFilterState: FilterState,
|
||||
omittedFilters: Record<string, string>[],
|
||||
@@ -104,16 +115,15 @@ export default function ScoresTable({
|
||||
hiddenColumns = [],
|
||||
localStorageSuffix = "",
|
||||
disableUrlPersistence = false,
|
||||
}: {
|
||||
projectId: string;
|
||||
userId?: string;
|
||||
traceId?: string;
|
||||
observationId?: string;
|
||||
omittedFilter?: string[];
|
||||
hiddenColumns?: string[];
|
||||
localStorageSuffix?: string;
|
||||
disableUrlPersistence?: boolean;
|
||||
}) {
|
||||
}: ScoresTableProps) {
|
||||
const scoresFilterConfig = useMemo(
|
||||
() => getScoreFilterConfig(hiddenColumns),
|
||||
[hiddenColumns],
|
||||
);
|
||||
const hiddenColumnSet = useMemo(
|
||||
() => new Set<string>(hiddenColumns),
|
||||
[hiddenColumns],
|
||||
);
|
||||
const { isBetaEnabled } = useV4Beta();
|
||||
// In v4beta, scores must exclusively use events-backed endpoints (no traces-table route).
|
||||
const useEventsBackedScores = isBetaEnabled;
|
||||
@@ -288,7 +298,7 @@ export default function ScoresTable({
|
||||
);
|
||||
|
||||
const queryFilter = useSidebarFilterState(
|
||||
scoreFilterConfig,
|
||||
scoresFilterConfig,
|
||||
newFilterOptions,
|
||||
{
|
||||
loading: filterOptions.isPending || environmentFilterOptions.isPending,
|
||||
@@ -322,7 +332,7 @@ export default function ScoresTable({
|
||||
const backendFilterState = transformFiltersForBackend(
|
||||
filterState,
|
||||
SCORE_COLUMN_TO_BACKEND_KEY,
|
||||
scoreFilterConfig.columnDefinitions,
|
||||
scoresFilterConfig.columnDefinitions,
|
||||
);
|
||||
|
||||
const getCountPayload = {
|
||||
@@ -714,7 +724,7 @@ export default function ScoresTable({
|
||||
];
|
||||
|
||||
const columns = rawColumns.filter(
|
||||
(c) => !!c.id && !hiddenColumns.includes(c.id),
|
||||
(c) => !!c.id && !hiddenColumnSet.has(c.id),
|
||||
);
|
||||
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
@@ -819,15 +829,15 @@ export default function ScoresTable({
|
||||
},
|
||||
validationContext: {
|
||||
columns,
|
||||
filterColumnDefinition: scoreFilterConfig.columnDefinitions,
|
||||
filterColumnDefinition: scoresFilterConfig.columnDefinitions,
|
||||
},
|
||||
currentFilterState: queryFilter.explicitFilterState,
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTableControlsProvider
|
||||
tableName={scoreFilterConfig.tableName}
|
||||
defaultSidebarCollapsed={scoreFilterConfig.defaultSidebarCollapsed}
|
||||
tableName={scoresFilterConfig.tableName}
|
||||
defaultSidebarCollapsed={scoresFilterConfig.defaultSidebarCollapsed}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* Toolbar spanning full width */}
|
||||
|
||||
@@ -594,12 +594,12 @@ export const ObservationPreview = ({
|
||||
<ScoresTable
|
||||
projectId={projectId}
|
||||
traceId={traceId}
|
||||
omittedFilter={["Observation ID"]}
|
||||
observationId={preloadedObservation.id}
|
||||
hiddenColumns={[
|
||||
"traceId",
|
||||
"observationId",
|
||||
"traceName",
|
||||
"traceTags",
|
||||
"jobConfigurationId",
|
||||
"userId",
|
||||
]}
|
||||
|
||||
@@ -583,9 +583,14 @@ export const TracePreview = ({
|
||||
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden pr-3 md:flex-1">
|
||||
<ScoresTable
|
||||
projectId={trace.projectId}
|
||||
omittedFilter={["Trace ID"]}
|
||||
traceId={trace.id}
|
||||
hiddenColumns={["traceName", "jobConfigurationId", "userId"]}
|
||||
hiddenColumns={[
|
||||
"traceId",
|
||||
"traceName",
|
||||
"traceTags",
|
||||
"jobConfigurationId",
|
||||
"userId",
|
||||
]}
|
||||
localStorageSuffix="TracePreview"
|
||||
disableUrlPersistence
|
||||
/>
|
||||
|
||||
+3
-1
@@ -129,6 +129,7 @@ export function ObservationDetailView({
|
||||
setJsonViewPreference,
|
||||
jsonBetaEnabled,
|
||||
setJsonBetaEnabled,
|
||||
isPeekMode,
|
||||
} = useViewPreferences();
|
||||
|
||||
// Map jsonViewPreference to currentView format expected by child components
|
||||
@@ -476,11 +477,12 @@ export function ObservationDetailView({
|
||||
"traceId",
|
||||
"observationId",
|
||||
"traceName",
|
||||
"traceTags",
|
||||
"jobConfigurationId",
|
||||
"userId",
|
||||
]}
|
||||
localStorageSuffix="ObservationPreview"
|
||||
disableUrlPersistence
|
||||
disableUrlPersistence={isPeekMode}
|
||||
/>
|
||||
</div>
|
||||
</TabsBarContent>
|
||||
|
||||
@@ -93,6 +93,7 @@ export function TraceDetailView({
|
||||
setJsonViewPreference,
|
||||
jsonBetaEnabled,
|
||||
setJsonBetaEnabled,
|
||||
isPeekMode,
|
||||
} = useViewPreferences();
|
||||
|
||||
// Map jsonViewPreference to currentView format expected by child components
|
||||
@@ -417,11 +418,16 @@ export function TraceDetailView({
|
||||
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden pr-3">
|
||||
<ScoresTable
|
||||
projectId={projectId}
|
||||
omittedFilter={["Trace ID"]}
|
||||
traceId={trace.id}
|
||||
hiddenColumns={["traceName", "jobConfigurationId", "userId"]}
|
||||
hiddenColumns={[
|
||||
"traceId",
|
||||
"traceName",
|
||||
"traceTags",
|
||||
"jobConfigurationId",
|
||||
"userId",
|
||||
]}
|
||||
localStorageSuffix="TracePreview"
|
||||
disableUrlPersistence
|
||||
disableUrlPersistence={isPeekMode}
|
||||
/>
|
||||
</div>
|
||||
</TabsBarContent>
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
type TableDensity = "compact" | "comfortable";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
@@ -74,7 +76,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background text-muted-foreground relative h-10 border-b px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
|
||||
"bg-background text-muted-foreground relative h-10 border-b px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -84,12 +86,13 @@ TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.TdHTMLAttributes<HTMLTableCellElement> & { density?: TableDensity }
|
||||
>(({ className, density = "compact", ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-full px-2 py-0 align-middle [&:has([role=checkbox])]:pr-0",
|
||||
"h-full align-middle [&:has([role=checkbox])]:pr-0",
|
||||
density === "comfortable" ? "p-2" : "px-2 py-0",
|
||||
"border-b [:last-child_>_&]:border-b-0",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.166.0";
|
||||
export const VERSION = "v3.167.4";
|
||||
|
||||
@@ -187,6 +187,7 @@ export function AuditLogsTable(props: AuditLogsTableProps) {
|
||||
state: paginationState,
|
||||
}}
|
||||
rowHeight={rowHeight}
|
||||
cellPadding="comfortable"
|
||||
/>
|
||||
</SettingsTableCard>
|
||||
</>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TraceAnnotationProcessor } from "./processors/TraceAnnotationProcessor"
|
||||
import { SessionAnnotationProcessor } from "./processors/SessionAnnotationProcessor";
|
||||
import { ObjectNotFoundCard } from "@/src/components/ui/object-not-found-card";
|
||||
import { useV4Beta } from "@/src/features/events/hooks/useV4Beta";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export const AnnotationQueueItemPage: React.FC<{
|
||||
annotationQueueId: string;
|
||||
@@ -25,6 +26,8 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
queryItemId?: string;
|
||||
}> = ({ annotationQueueId, projectId, view, queryItemId }) => {
|
||||
const router = useRouter();
|
||||
const { status: sessionStatus } = useSession();
|
||||
const sessionLoaded = sessionStatus !== "loading";
|
||||
const { isBetaEnabled } = useV4Beta();
|
||||
const isSingleItem = router.query.singleItem === "true";
|
||||
const [nextItemData, setNextItemData] = useState<
|
||||
@@ -42,7 +45,7 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
|
||||
const seenItemData = api.annotationQueueItems.byId.useQuery(
|
||||
{ projectId, itemId: itemId as string, isBetaEnabled },
|
||||
{ enabled: !!itemId, refetchOnMount: false },
|
||||
{ enabled: !!itemId && sessionLoaded, refetchOnMount: false },
|
||||
);
|
||||
|
||||
const fetchAndLockNextMutation =
|
||||
@@ -51,18 +54,19 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
async function fetchNextItem() {
|
||||
if (!itemId && !isSingleItem) {
|
||||
if (!itemId && !isSingleItem && sessionLoaded) {
|
||||
const nextItem = await fetchAndLockNextMutation.mutateAsync({
|
||||
queueId: annotationQueueId,
|
||||
projectId,
|
||||
seenItemIds,
|
||||
isBetaEnabled,
|
||||
});
|
||||
setNextItemData(nextItem);
|
||||
}
|
||||
}
|
||||
fetchNextItem();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [sessionLoaded]);
|
||||
const { configs } = useAnnotationQueueData({ annotationQueueId, projectId });
|
||||
|
||||
const unseenPendingItemCount =
|
||||
@@ -88,6 +92,7 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
queueId: annotationQueueId,
|
||||
projectId,
|
||||
seenItemIds,
|
||||
isBetaEnabled,
|
||||
});
|
||||
setNextItemData(nextItem);
|
||||
}
|
||||
@@ -144,7 +149,8 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
(seenItemData.isPending && itemId) ||
|
||||
(fetchAndLockNextMutation.isPending && !itemId) ||
|
||||
unseenPendingItemCount.isPending ||
|
||||
objectData.isLoading
|
||||
objectData.isLoading ||
|
||||
(!sessionLoaded && !isSingleItem)
|
||||
) {
|
||||
return <Skeleton className="h-full w-full" />;
|
||||
}
|
||||
@@ -165,6 +171,7 @@ export const AnnotationQueueItemPage: React.FC<{
|
||||
queueId: annotationQueueId,
|
||||
projectId,
|
||||
seenItemIds,
|
||||
isBetaEnabled,
|
||||
});
|
||||
setNextItemData(nextItem);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
optionalPaginationZod,
|
||||
Prisma,
|
||||
} from "@langfuse/shared";
|
||||
import { getObservationById, logger } from "@langfuse/shared/src/server";
|
||||
import {
|
||||
getObservationById,
|
||||
getObservationByIdFromEventsTable,
|
||||
logger,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -464,78 +468,73 @@ export const queueRouter = createTRPCRouter({
|
||||
queueId: z.string(),
|
||||
projectId: z.string(),
|
||||
seenItemIds: z.array(z.string()),
|
||||
isBetaEnabled: z.boolean().optional().default(false),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
throwIfNoProjectAccess({
|
||||
session: ctx.session,
|
||||
projectId: input.projectId,
|
||||
scope: "annotationQueues:CUD",
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
|
||||
const item = await ctx.prisma.annotationQueueItem.findFirst({
|
||||
where: {
|
||||
queueId: input.queueId,
|
||||
projectId: input.projectId,
|
||||
scope: "annotationQueues:CUD",
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
|
||||
const item = await ctx.prisma.annotationQueueItem.findFirst({
|
||||
where: {
|
||||
queueId: input.queueId,
|
||||
projectId: input.projectId,
|
||||
status: AnnotationQueueStatus.PENDING,
|
||||
OR: [
|
||||
{ lockedAt: null },
|
||||
{ lockedAt: { lt: fiveMinutesAgo } },
|
||||
{ lockedByUserId: ctx.session.user.id },
|
||||
],
|
||||
NOT: {
|
||||
id: { in: input.seenItemIds },
|
||||
},
|
||||
status: AnnotationQueueStatus.PENDING,
|
||||
OR: [
|
||||
{ lockedAt: null },
|
||||
{ lockedAt: { lt: fiveMinutesAgo } },
|
||||
{ lockedByUserId: ctx.session.user.id },
|
||||
],
|
||||
NOT: {
|
||||
id: { in: input.seenItemIds },
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// Expected behavior, non-error case: all items have been seen AND/OR completed, no more unseen pending items
|
||||
if (!item) return null;
|
||||
// Expected behavior, non-error case: all items have been seen AND/OR completed, no more unseen pending items
|
||||
if (!item) return null;
|
||||
|
||||
const updatedItem = await ctx.prisma.annotationQueueItem.update({
|
||||
where: {
|
||||
id: item.id,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
data: {
|
||||
lockedAt: now,
|
||||
lockedByUserId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
const updatedItem = await ctx.prisma.annotationQueueItem.update({
|
||||
where: {
|
||||
id: item.id,
|
||||
projectId: input.projectId,
|
||||
},
|
||||
data: {
|
||||
lockedAt: now,
|
||||
lockedByUserId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const inflatedUpdatedItem = {
|
||||
...updatedItem,
|
||||
lockedByUser: { name: ctx.session.user.name },
|
||||
const inflatedUpdatedItem = {
|
||||
...updatedItem,
|
||||
lockedByUser: { name: ctx.session.user.name },
|
||||
};
|
||||
|
||||
if (item.objectType === AnnotationQueueObjectType.OBSERVATION) {
|
||||
const clickhouseObservation = input.isBetaEnabled
|
||||
? await getObservationByIdFromEventsTable({
|
||||
id: item.objectId,
|
||||
projectId: input.projectId,
|
||||
})
|
||||
: await getObservationById({
|
||||
id: item.objectId,
|
||||
projectId: input.projectId,
|
||||
});
|
||||
return {
|
||||
...inflatedUpdatedItem,
|
||||
parentTraceId: clickhouseObservation?.traceId,
|
||||
};
|
||||
|
||||
if (item.objectType === AnnotationQueueObjectType.OBSERVATION) {
|
||||
const clickhouseObservation = await getObservationById({
|
||||
id: item.objectId,
|
||||
projectId: input.projectId,
|
||||
});
|
||||
return {
|
||||
...inflatedUpdatedItem,
|
||||
parentTraceId: clickhouseObservation?.traceId,
|
||||
};
|
||||
}
|
||||
|
||||
return inflatedUpdatedItem;
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Fetching and locking next annotation queue item failed.",
|
||||
});
|
||||
}
|
||||
|
||||
return inflatedUpdatedItem;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -17,12 +17,21 @@ export const passwordSchema = z
|
||||
"Please choose a secure password by combining letters, numbers, and special characters.",
|
||||
});
|
||||
|
||||
export const signupSchema = z.object({
|
||||
name: StringNoHTMLNonEmpty.refine((value) => noUrlCheck(value), {
|
||||
export const nameSchema = StringNoHTMLNonEmpty.max(
|
||||
100,
|
||||
"Name must be at most 100 characters",
|
||||
)
|
||||
.transform((value) => value.normalize("NFC").replace(/[\u2018\u2019]/g, "'"))
|
||||
.refine((value) => noUrlCheck(value), {
|
||||
message: "Input should not contain a URL",
|
||||
}).refine((value) => /^[a-zA-Z0-9\s]+$/.test(value), {
|
||||
message: "Name can only contain letters, numbers, and spaces",
|
||||
}),
|
||||
})
|
||||
.refine((value) => /^\p{L}[\p{L}\p{M}\p{N}\s.'\-]*$/u.test(value), {
|
||||
message:
|
||||
"Name must start with a letter and can only contain letters, numbers, spaces, hyphens, apostrophes, and periods",
|
||||
});
|
||||
|
||||
export const signupSchema = z.object({
|
||||
name: nameSchema,
|
||||
email: z.string().email(),
|
||||
password: passwordSchema,
|
||||
referralSource: z.string().optional(),
|
||||
|
||||
@@ -31,8 +31,12 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
|
||||
disabled,
|
||||
projectId,
|
||||
}) => {
|
||||
const initialChannelId = form.getValues("slack.channelId") as string;
|
||||
const initialChannelName = form.getValues("slack.channelName") as string;
|
||||
const [selectedChannel, setSelectedChannel] = useState<SlackChannel | null>(
|
||||
null,
|
||||
initialChannelId && initialChannelName
|
||||
? { id: initialChannelId, name: initialChannelName }
|
||||
: null,
|
||||
);
|
||||
|
||||
// Get Slack integration status
|
||||
@@ -88,6 +92,7 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
|
||||
<ChannelSelector
|
||||
projectId={projectId}
|
||||
selectedChannelId={field.value}
|
||||
selectedChannel={selectedChannel}
|
||||
onChannelSelect={handleChannelSelect}
|
||||
disabled={disabled}
|
||||
placeholder="Select a channel"
|
||||
@@ -96,7 +101,12 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Select the Slack channel where notifications will be sent.
|
||||
Select the Slack channel where notifications will be sent. For
|
||||
private channels, invite the app first with{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5">
|
||||
/invite @Langfuse
|
||||
</code>{" "}
|
||||
in that channel.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -120,6 +130,23 @@ export const SlackActionForm: React.FC<SlackActionFormProps> = ({
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
buttonText="Test Channel"
|
||||
onSuccess={(channelInfo) => {
|
||||
form.setValue("slack.channelId", channelInfo.id);
|
||||
form.setValue(
|
||||
"slack.channelName",
|
||||
channelInfo.name ?? selectedChannel?.name ?? "",
|
||||
);
|
||||
setSelectedChannel((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
id: channelInfo.id,
|
||||
name: channelInfo.name ?? prev.name,
|
||||
isPrivate: channelInfo.isPrivate ?? prev.isPrivate,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Test this channel to verify the bot can send messages.
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
createTRPCRouter,
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { blobStorageIntegrationFormSchema } from "@/src/features/blobstorage-integration/types";
|
||||
import { blobStorageIntegrationFormSchemaBase } from "@/src/features/blobstorage-integration/types";
|
||||
import { validateAzureContainerName } from "@/src/features/blobstorage-integration/validation";
|
||||
import { upsertBlobStorageIntegration } from "@/src/features/blobstorage-integration/service";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
@@ -56,7 +57,11 @@ export const blobStorageIntegrationRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
update: protectedProjectProcedure
|
||||
.input(blobStorageIntegrationFormSchema.extend({ projectId: z.string() }))
|
||||
.input(
|
||||
blobStorageIntegrationFormSchemaBase
|
||||
.extend({ projectId: z.string() })
|
||||
.superRefine(validateAzureContainerName),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
throwIfNoProjectAccess({
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
BlobStorageExportMode,
|
||||
AnalyticsIntegrationExportSource,
|
||||
} from "@langfuse/shared";
|
||||
import { validateAzureContainerName } from "@/src/features/blobstorage-integration/validation";
|
||||
|
||||
export const blobStorageIntegrationFormSchema = z.object({
|
||||
export const blobStorageIntegrationFormSchemaBase = z.object({
|
||||
type: z.enum(BlobStorageIntegrationType),
|
||||
bucketName: z.string().min(1, { message: "Bucket name is required" }),
|
||||
endpoint: z.string().url().optional().nullable(),
|
||||
@@ -36,6 +37,9 @@ export const blobStorageIntegrationFormSchema = z.object({
|
||||
compressed: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const blobStorageIntegrationFormSchema =
|
||||
blobStorageIntegrationFormSchemaBase.superRefine(validateAzureContainerName);
|
||||
|
||||
export type BlobStorageIntegrationFormSchema = z.infer<
|
||||
typeof blobStorageIntegrationFormSchema
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
AZURE_CONTAINER_NAME_REGEX,
|
||||
validateAzureContainerName,
|
||||
} from "./validation";
|
||||
import { z } from "zod";
|
||||
|
||||
describe("AZURE_CONTAINER_NAME_REGEX", () => {
|
||||
const valid = [
|
||||
"abc",
|
||||
"my-container",
|
||||
"a1b2c3",
|
||||
"123",
|
||||
"a-b",
|
||||
"a".repeat(63),
|
||||
"container-name-1",
|
||||
];
|
||||
|
||||
const invalid = [
|
||||
"ab", // too short
|
||||
"a", // too short
|
||||
"a".repeat(64), // too long
|
||||
"ABC", // uppercase
|
||||
"My-Container", // mixed case
|
||||
"-abc", // starts with hyphen
|
||||
"abc-", // ends with hyphen
|
||||
"my--container", // consecutive hyphens
|
||||
"has space", // spaces
|
||||
"has.dot", // dots
|
||||
"has/slash", // slashes
|
||||
"Feedback N8N Bot", // the original issue
|
||||
"", // empty
|
||||
];
|
||||
|
||||
it.each(valid)("accepts valid name: %s", (name) => {
|
||||
expect(AZURE_CONTAINER_NAME_REGEX.test(name)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(invalid)("rejects invalid name: %s", (name) => {
|
||||
expect(AZURE_CONTAINER_NAME_REGEX.test(name)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAzureContainerName via schema", () => {
|
||||
const schema = z
|
||||
.object({ type: z.string(), bucketName: z.string() })
|
||||
.superRefine(validateAzureContainerName);
|
||||
|
||||
it("rejects invalid Azure container name", () => {
|
||||
const result = schema.safeParse({
|
||||
type: "AZURE_BLOB_STORAGE",
|
||||
bucketName: "Feedback N8N Bot",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toEqual(["bucketName"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows invalid container name for S3 type", () => {
|
||||
const result = schema.safeParse({
|
||||
type: "S3",
|
||||
bucketName: "Feedback N8N Bot",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("allows valid Azure container name", () => {
|
||||
const result = schema.safeParse({
|
||||
type: "AZURE_BLOB_STORAGE",
|
||||
bucketName: "valid-container",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("skips Azure validation when bucketName is empty", () => {
|
||||
const result = schema.safeParse({
|
||||
type: "AZURE_BLOB_STORAGE",
|
||||
bucketName: "",
|
||||
});
|
||||
// Should pass superRefine (empty guard), letting .min(1) handle it upstream
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Azure container names must be 3-63 characters, lowercase letters, numbers,
|
||||
* and hyphens only. Must start and end with a letter or number. No consecutive
|
||||
* hyphens.
|
||||
*
|
||||
* @see https://learn.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata#container-names
|
||||
*/
|
||||
export const AZURE_CONTAINER_NAME_REGEX =
|
||||
/^[a-z0-9](?!.*--)[a-z0-9-]{1,61}[a-z0-9]$/;
|
||||
|
||||
export const AZURE_CONTAINER_NAME_ERROR =
|
||||
"Azure container names must be 3-63 characters, lowercase letters, numbers, and hyphens only. Must start and end with a letter or number, no consecutive hyphens.";
|
||||
|
||||
export function validateAzureContainerName(
|
||||
data: { type: string; bucketName: string },
|
||||
ctx: z.RefinementCtx,
|
||||
) {
|
||||
if (!data.bucketName) return;
|
||||
if (
|
||||
data.type === "AZURE_BLOB_STORAGE" &&
|
||||
!AZURE_CONTAINER_NAME_REGEX.test(data.bucketName)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: AZURE_CONTAINER_NAME_ERROR,
|
||||
path: ["bucketName"],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -102,11 +102,20 @@ export function SelectDashboardDialog({
|
||||
selectedDashboardId === d.id ? "bg-muted" : ""
|
||||
}`}
|
||||
>
|
||||
<TableCell className="font-medium">{d.name}</TableCell>
|
||||
<TableCell className="truncate" title={d.description}>
|
||||
<TableCell
|
||||
density="comfortable"
|
||||
className="font-medium"
|
||||
>
|
||||
{d.name}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
density="comfortable"
|
||||
className="truncate"
|
||||
title={d.description}
|
||||
>
|
||||
{d.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell density="comfortable">
|
||||
{new Date(d.updatedAt).toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useIsAuthenticatedAndProjectMember } from "@/src/features/auth/hooks";
|
||||
import { parseJsonPrioritised } from "@langfuse/shared";
|
||||
import { ActionButton } from "@/src/components/ActionButton";
|
||||
import { type MetadataDomainClient } from "@/src/utils/clientSideDomainTypes";
|
||||
import { type Prisma } from "@langfuse/shared";
|
||||
|
||||
/**
|
||||
* Component for creating a new dataset item from an existing object.
|
||||
@@ -39,22 +40,30 @@ export const NewDatasetItemFromExistingObject = (props: {
|
||||
traceId?: string;
|
||||
observationId?: string;
|
||||
fromDatasetId?: string;
|
||||
input: string | null;
|
||||
output: string | null;
|
||||
input: Prisma.JsonValue | null;
|
||||
output: Prisma.JsonValue | null;
|
||||
metadata: MetadataDomainClient;
|
||||
isCopyItem?: boolean;
|
||||
buttonVariant?: ButtonProps["variant"];
|
||||
size?: ButtonProps["size"];
|
||||
}) => {
|
||||
const parsedInput =
|
||||
props.input && typeof props.input === "string"
|
||||
? (parseJsonPrioritised(props.input) ?? null)
|
||||
: null;
|
||||
const normalizePrefillValue = (
|
||||
value: Prisma.JsonValue | null,
|
||||
): Prisma.JsonValue | null => {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedOutput =
|
||||
props.output && typeof props.output === "string"
|
||||
? (parseJsonPrioritised(props.output) ?? null)
|
||||
: null;
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseJsonPrioritised(value);
|
||||
return parsed !== undefined ? parsed : value;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const parsedInput = normalizePrefillValue(props.input);
|
||||
const parsedOutput = normalizePrefillValue(props.output);
|
||||
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const isAuthenticatedAndProjectMember = useIsAuthenticatedAndProjectMember(
|
||||
|
||||
@@ -28,6 +28,7 @@ import { api } from "@/src/utils/api";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
import { JSONView } from "@/src/components/ui/CodeJsonViewer";
|
||||
import { decomposeAggregateScoreKey } from "@/src/features/scores/lib/aggregateScores";
|
||||
import { cn } from "@/src/utils/tailwind";
|
||||
|
||||
type ExperimentGridCellProps = {
|
||||
projectId: string;
|
||||
@@ -49,6 +50,7 @@ type ExperimentGridCellProps = {
|
||||
baselineTraceScores?: ScoreAggregate;
|
||||
isLoading?: boolean;
|
||||
columnVisibility?: VisibilityState;
|
||||
markerClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -263,15 +265,27 @@ const MetadataItem = ({
|
||||
const GroupSection = ({
|
||||
header,
|
||||
children,
|
||||
markerClassName,
|
||||
}: {
|
||||
header?: string;
|
||||
children: React.ReactNode;
|
||||
markerClassName?: string;
|
||||
}) => (
|
||||
<div className="flex shrink-0 flex-col gap-1 px-2 py-1.5">
|
||||
{header && (
|
||||
<span className="text-muted-foreground text-[10px] font-semibold uppercase">
|
||||
{header}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{markerClassName !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"h-3 w-0.5 shrink-0 rounded-full",
|
||||
markerClassName || "bg-transparent",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="text-muted-foreground text-[10px] font-semibold uppercase">
|
||||
{header}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
@@ -300,6 +314,7 @@ export const ExperimentGridCell = ({
|
||||
baselineTraceScores,
|
||||
isLoading = false,
|
||||
columnVisibility = {},
|
||||
markerClassName,
|
||||
}: ExperimentGridCellProps) => {
|
||||
const scoreDiffs = useMemo(
|
||||
() =>
|
||||
@@ -492,45 +507,50 @@ export const ExperimentGridCell = ({
|
||||
.filter((section) => section.content !== null);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{sectionsToRender.map((section, index) => {
|
||||
const { row, content } = section;
|
||||
const isLast = index === sectionsToRender.length - 1;
|
||||
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-y-auto">
|
||||
{sectionsToRender.map((section, index) => {
|
||||
const { row, content } = section;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === sectionsToRender.length - 1;
|
||||
|
||||
// Output section - special handling for MemoizedIOTableCell
|
||||
if (row.accessorKey === "output" && row.cell) {
|
||||
return (
|
||||
<Fragment key={row.accessorKey}>
|
||||
<GroupSection header={row.header}>
|
||||
{row.cell({ data: cellData })}
|
||||
</GroupSection>
|
||||
{!isLast && <Separator />}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
// Output section - special handling for MemoizedIOTableCell
|
||||
if (row.accessorKey === "output" && row.cell) {
|
||||
return (
|
||||
<Fragment key={row.accessorKey}>
|
||||
<GroupSection
|
||||
header={row.header}
|
||||
markerClassName={isFirst ? markerClassName : undefined}
|
||||
>
|
||||
{row.cell({ data: cellData })}
|
||||
</GroupSection>
|
||||
{!isLast && <Separator />}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// Groups with children (metadata, scores)
|
||||
if (row.children && content) {
|
||||
return (
|
||||
<Fragment key={row.accessorKey}>
|
||||
<GroupSection header={row.header}>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{(content as CellRowDef<GridCellData>[]).map((child) => (
|
||||
<div key={child.accessorKey}>
|
||||
{child.cell?.({ data: cellData })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GroupSection>
|
||||
{!isLast && <Separator />}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
// Groups with children (metadata, scores)
|
||||
if (row.children && content) {
|
||||
return (
|
||||
<Fragment key={row.accessorKey}>
|
||||
<GroupSection
|
||||
header={row.header}
|
||||
markerClassName={isFirst ? markerClassName : undefined}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{(content as CellRowDef<GridCellData>[]).map((child) => (
|
||||
<div key={child.accessorKey}>
|
||||
{child.cell?.({ data: cellData })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GroupSection>
|
||||
{!isLast && <Separator />}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
ExperimentGridCell,
|
||||
ExperimentGridCellEmpty,
|
||||
} from "./ExperimentGridCell";
|
||||
import { type ExperimentItemsTableRow, getExperimentColor } from "./types";
|
||||
import {
|
||||
type ExperimentItemsTableRow,
|
||||
getExperimentColorStyles,
|
||||
} from "./types";
|
||||
import { useMemo } from "react";
|
||||
import { type RowHeight } from "@/src/components/table/data-table-row-height-switch";
|
||||
import {
|
||||
@@ -79,8 +82,8 @@ export const ExperimentGridView = ({
|
||||
const isBaseline = index === 0;
|
||||
const expInfo = experimentNames.find((e) => e.experimentId === expId);
|
||||
const expName = expInfo?.experimentName ?? expId.slice(0, 8);
|
||||
const colorClass = useExperimentColors
|
||||
? getExperimentColor(expId, allExperimentIds)
|
||||
const colorStyles = useExperimentColors
|
||||
? getExperimentColorStyles(expId, allExperimentIds)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
@@ -88,16 +91,18 @@ export const ExperimentGridView = ({
|
||||
id: expId,
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("truncate font-medium", colorClass)}>
|
||||
<span
|
||||
className={cn("truncate font-medium", colorStyles?.textClass)}
|
||||
>
|
||||
{expName}
|
||||
</span>
|
||||
{isBaseline && useExperimentColors && (
|
||||
{useExperimentColors && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 font-medium"
|
||||
className={cn("shrink-0 font-medium", colorStyles?.badgeClass)}
|
||||
>
|
||||
Baseline
|
||||
{isBaseline ? "Baseline" : "Comp"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -144,6 +149,7 @@ export const ExperimentGridView = ({
|
||||
baselineScores={baselineData?.observationScores}
|
||||
baselineTraceScores={baselineData?.traceScores}
|
||||
columnVisibility={columnVisibility}
|
||||
markerClassName={colorStyles?.markerClass}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -212,6 +218,7 @@ export const ExperimentGridView = ({
|
||||
customRowHeights={GRID_VIEW_ROW_HEIGHTS}
|
||||
topAlignCells
|
||||
peekView={peekView}
|
||||
columnVisibility={columnVisibility}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
type ExperimentItemsTableProps,
|
||||
type ExperimentItemData,
|
||||
type ExperimentOutputData,
|
||||
getExperimentColor,
|
||||
getExperimentColorStyles,
|
||||
} from "./types";
|
||||
import { MemoizedIOTableCell } from "@/src/components/ui/IOTableCell";
|
||||
import {
|
||||
@@ -101,21 +101,28 @@ const StackedExperimentCell = ({
|
||||
>
|
||||
{allExperimentIds.map((experimentId) => {
|
||||
const exp = experimentsById.get(experimentId);
|
||||
const colorStyles = getExperimentColorStyles(
|
||||
experimentId,
|
||||
colorExperimentIds ?? allExperimentIds,
|
||||
);
|
||||
const content = exp ? renderValue(exp) : null;
|
||||
return (
|
||||
<div
|
||||
key={experimentId}
|
||||
className={cn(
|
||||
"flex min-h-0 items-start overflow-hidden px-2",
|
||||
getExperimentColor(
|
||||
experimentId,
|
||||
colorExperimentIds ?? allExperimentIds,
|
||||
),
|
||||
)}
|
||||
className="flex min-h-0 items-start overflow-hidden py-0.5 pr-2 pl-1.5"
|
||||
>
|
||||
{exp ? (
|
||||
renderValue(exp)
|
||||
{content ? (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 mr-2 block h-4 w-0.5 shrink-0 rounded-full",
|
||||
colorStyles.markerClass,
|
||||
)}
|
||||
/>
|
||||
{content}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -154,32 +161,41 @@ const StackedOutputCell = ({
|
||||
>
|
||||
{allExperimentIds.map((experimentId) => {
|
||||
const out = outputsByExperimentId.get(experimentId);
|
||||
const colorStyles = getExperimentColorStyles(
|
||||
experimentId,
|
||||
colorExperimentIds ?? allExperimentIds,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={experimentId}
|
||||
className={cn(
|
||||
"flex min-h-0 items-start overflow-hidden",
|
||||
getExperimentColor(
|
||||
experimentId,
|
||||
colorExperimentIds ?? allExperimentIds,
|
||||
),
|
||||
)}
|
||||
className="flex min-h-0 items-start overflow-hidden py-0.5 pr-1 pl-1.5"
|
||||
>
|
||||
{isLoading ? (
|
||||
<MemoizedIOTableCell
|
||||
isLoading={true}
|
||||
data={null}
|
||||
singleLine={singleLine}
|
||||
/>
|
||||
<div className="flex min-w-0 items-start">
|
||||
<span className="bg-muted mt-0.5 mr-2 block h-4 w-0.5 shrink-0 rounded-full" />
|
||||
<MemoizedIOTableCell
|
||||
isLoading={true}
|
||||
data={null}
|
||||
singleLine={singleLine}
|
||||
/>
|
||||
</div>
|
||||
) : out?.output ? (
|
||||
<MemoizedIOTableCell
|
||||
isLoading={false}
|
||||
data={out.output}
|
||||
singleLine={singleLine}
|
||||
className="bg-accent-light-green"
|
||||
/>
|
||||
<div className="flex min-w-0 items-start">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 mr-2 block h-4 w-0.5 shrink-0 rounded-full",
|
||||
colorStyles.markerClass,
|
||||
)}
|
||||
/>
|
||||
<MemoizedIOTableCell
|
||||
isLoading={false}
|
||||
data={out.output}
|
||||
singleLine={singleLine}
|
||||
className="bg-accent-light-green"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground px-2 py-1">-</span>
|
||||
<span className="text-muted-foreground px-2 py-1">—</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -301,11 +301,29 @@ export default function ExperimentsTable({
|
||||
header: getExperimentsColumnName("experimentDatasetId"),
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const key: string | undefined = row.getValue("datasetId");
|
||||
const value = filterOptions.experimentDatasetId?.find(
|
||||
(d) => d.value === key,
|
||||
const datasetId: string | undefined = row.getValue("datasetId");
|
||||
const datasetName = filterOptions.experimentDatasetId?.find(
|
||||
(d) => d.value === datasetId,
|
||||
)?.displayValue;
|
||||
return value ? <TableIdOrName value={value} /> : undefined;
|
||||
|
||||
if (!datasetId || !datasetName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/project/${projectId}/datasets/${encodeURIComponent(datasetId)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/80 max-w-full cursor-pointer"
|
||||
>
|
||||
{datasetName}
|
||||
</Badge>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,14 +3,41 @@ import { type VisibilityState } from "@tanstack/react-table";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
// Shared font color palette for experiment rows/columns
|
||||
export const EXPERIMENT_COLORS = [
|
||||
"text-dark-gray", // Baseline - index 0
|
||||
"text-blue-700", // Comparison 1
|
||||
"text-pink-700", // Comparison 2
|
||||
"text-purple-700", // Comparison 3
|
||||
"text-orange-700", // Comparison 4
|
||||
export const EXPERIMENT_COLOR_STYLES = [
|
||||
{
|
||||
textClass: "text-dark-gray",
|
||||
markerClass: "bg-slate-500 dark:bg-slate-400",
|
||||
badgeClass:
|
||||
"border-slate-400/80 bg-slate-100/70 text-slate-700 dark:border-slate-500/70 dark:bg-slate-900/60 dark:text-slate-300",
|
||||
}, // Baseline - index 0
|
||||
{
|
||||
textClass: "text-blue-700 dark:text-blue-300",
|
||||
markerClass: "bg-blue-500/80 dark:bg-blue-400/80",
|
||||
badgeClass:
|
||||
"border-blue-500/45 bg-blue-500/12 text-blue-700 dark:border-blue-400/45 dark:bg-blue-400/15 dark:text-blue-300",
|
||||
}, // Comparison 1
|
||||
{
|
||||
textClass: "text-violet-700 dark:text-violet-300",
|
||||
markerClass: "bg-violet-500/80 dark:bg-violet-400/80",
|
||||
badgeClass:
|
||||
"border-violet-500/45 bg-violet-500/12 text-violet-700 dark:border-violet-400/45 dark:bg-violet-400/15 dark:text-violet-300",
|
||||
}, // Comparison 2
|
||||
{
|
||||
textClass: "text-teal-700 dark:text-teal-300",
|
||||
markerClass: "bg-teal-500/80 dark:bg-teal-400/80",
|
||||
badgeClass:
|
||||
"border-teal-500/45 bg-teal-500/12 text-teal-700 dark:border-teal-400/45 dark:bg-teal-400/15 dark:text-teal-300",
|
||||
}, // Comparison 3
|
||||
{
|
||||
textClass: "text-amber-700 dark:text-amber-300",
|
||||
markerClass: "bg-amber-500/80 dark:bg-amber-400/80",
|
||||
badgeClass:
|
||||
"border-amber-500/45 bg-amber-500/12 text-amber-700 dark:border-amber-400/45 dark:bg-amber-400/15 dark:text-amber-300",
|
||||
}, // Comparison 4
|
||||
] as const;
|
||||
|
||||
export type ExperimentColorStyle = (typeof EXPERIMENT_COLOR_STYLES)[number];
|
||||
|
||||
/**
|
||||
* Get the text color class for an experiment based on its index.
|
||||
*/
|
||||
@@ -18,8 +45,19 @@ export const getExperimentColor = (
|
||||
experimentId: string,
|
||||
allExperimentIds: string[],
|
||||
): string => {
|
||||
const styles = getExperimentColorStyles(experimentId, allExperimentIds);
|
||||
return styles.textClass;
|
||||
};
|
||||
|
||||
export const getExperimentColorStyles = (
|
||||
experimentId: string,
|
||||
allExperimentIds: string[],
|
||||
): ExperimentColorStyle => {
|
||||
const index = allExperimentIds.indexOf(experimentId);
|
||||
return EXPERIMENT_COLORS[index % EXPERIMENT_COLORS.length];
|
||||
return (
|
||||
EXPERIMENT_COLOR_STYLES[index % EXPERIMENT_COLOR_STYLES.length] ??
|
||||
EXPERIMENT_COLOR_STYLES[0]
|
||||
);
|
||||
};
|
||||
|
||||
export type ExperimentsTableRow = {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getScoreFilterConfig } from "./scores-config";
|
||||
|
||||
describe("getScoreFilterConfig", () => {
|
||||
it("omits sidebar facets for hidden score columns", () => {
|
||||
const config = getScoreFilterConfig([
|
||||
"traceId",
|
||||
"traceName",
|
||||
"observationId",
|
||||
"traceTags",
|
||||
]);
|
||||
|
||||
expect(config.facets.map((facet) => facet.column)).not.toContain("traceId");
|
||||
expect(config.facets.map((facet) => facet.column)).not.toContain(
|
||||
"traceName",
|
||||
);
|
||||
expect(config.facets.map((facet) => facet.column)).not.toContain(
|
||||
"observationId",
|
||||
);
|
||||
expect(config.facets.map((facet) => facet.column)).not.toContain("tags");
|
||||
expect(config.facets.map((facet) => facet.column)).toContain("userId");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,20 @@ export const SCORE_COLUMN_TO_BACKEND_KEY: ColumnToBackendKeyMap = {
|
||||
tags: "trace_tags",
|
||||
};
|
||||
|
||||
export type ScoresTableHiddenColumn =
|
||||
| "traceId"
|
||||
| "traceName"
|
||||
| "observationId"
|
||||
| "jobConfigurationId"
|
||||
| "userId"
|
||||
| "traceTags";
|
||||
|
||||
const SCORES_HIDDEN_COLUMN_TO_FILTER_COLUMN: Partial<
|
||||
Record<ScoresTableHiddenColumn, string>
|
||||
> = {
|
||||
traceTags: "tags",
|
||||
};
|
||||
|
||||
export const scoreFilterConfig: FilterConfig = {
|
||||
tableName: "scores",
|
||||
|
||||
@@ -83,3 +97,26 @@ export const scoreFilterConfig: FilterConfig = {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function getScoreFilterConfig(
|
||||
hiddenColumns: ScoresTableHiddenColumn[] = [],
|
||||
): FilterConfig {
|
||||
if (hiddenColumns.length === 0) {
|
||||
return scoreFilterConfig;
|
||||
}
|
||||
const hiddenColumnSet = new Set<string>(
|
||||
hiddenColumns.map(
|
||||
(column) => SCORES_HIDDEN_COLUMN_TO_FILTER_COLUMN[column] ?? column,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
...scoreFilterConfig,
|
||||
defaultExpanded: scoreFilterConfig.defaultExpanded?.filter(
|
||||
(column) => !hiddenColumnSet.has(column),
|
||||
),
|
||||
facets: scoreFilterConfig.facets.filter(
|
||||
(facet) => !hiddenColumnSet.has(facet.column),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
blockEvaluatorConfigsInTx,
|
||||
EvaluatorBlockSource,
|
||||
finalizeBlockedEvaluatorConfigBlocks,
|
||||
validateLlmConnectionBaseURL,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { env } from "@/src/env.mjs";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -130,6 +131,27 @@ async function testLLMConnection(
|
||||
}
|
||||
}
|
||||
|
||||
async function validateBaseURLForWrite(params: {
|
||||
baseURL?: string | null;
|
||||
errorPrefix?: string;
|
||||
}): Promise<void> {
|
||||
if (!params.baseURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await validateLlmConnectionBaseURL(params.baseURL);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? `${params.errorPrefix ?? "Invalid base URL"}: ${error.message}`
|
||||
: (params.errorPrefix ?? "Invalid base URL"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const llmApiKeyRouter = createTRPCRouter({
|
||||
create: protectedProjectProcedureWithoutTracing
|
||||
.input(CreateLlmApiKey)
|
||||
@@ -141,6 +163,10 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
scope: "llmApiKeys:create",
|
||||
});
|
||||
|
||||
await validateBaseURLForWrite({
|
||||
baseURL: input.baseURL,
|
||||
});
|
||||
|
||||
// Validate that default credentials sentinel is only allowed for Bedrock/VertexAI in self-hosted deployments
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
|
||||
@@ -406,6 +432,17 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
scope: "llmApiKeys:create",
|
||||
});
|
||||
|
||||
if (input.baseURL) {
|
||||
try {
|
||||
await validateLlmConnectionBaseURL(input.baseURL);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Invalid base URL",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return testLLMConnection({
|
||||
adapter: input.adapter,
|
||||
provider: input.provider,
|
||||
@@ -442,25 +479,41 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const decryptedSecretKey =
|
||||
input.secretKey !== undefined &&
|
||||
input.secretKey !== "" &&
|
||||
input.secretKey !== null
|
||||
? input.secretKey
|
||||
: decrypt(existingKey.secretKey);
|
||||
const hasNewSecretKey =
|
||||
typeof input.secretKey === "string" && input.secretKey.length > 0;
|
||||
const baseURL = input.baseURL ?? existingKey.baseURL;
|
||||
const isBaseURLChanged = baseURL !== existingKey.baseURL;
|
||||
|
||||
if (isBaseURLChanged && !hasNewSecretKey) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Secret key is required when changing the base URL",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.baseURL && isBaseURLChanged) {
|
||||
await validateLlmConnectionBaseURL(input.baseURL);
|
||||
}
|
||||
|
||||
const secretKey = hasNewSecretKey
|
||||
? (input.secretKey as string)
|
||||
: decrypt(existingKey.secretKey);
|
||||
|
||||
// Merge existing key with provided input, giving priority to input
|
||||
const secretKey = decryptedSecretKey;
|
||||
const adapter = input.adapter ?? (existingKey.adapter as LLMAdapter);
|
||||
const provider = input.provider ?? existingKey.provider;
|
||||
const baseURL = input.baseURL ?? existingKey.baseURL;
|
||||
const customModels = input.customModels ?? existingKey.customModels;
|
||||
const config = input.config ?? existingKey.config;
|
||||
|
||||
// Never reuse stored headers across a destination change.
|
||||
const extraHeaders =
|
||||
input.extraHeaders ??
|
||||
(existingKey.extraHeaders
|
||||
? decryptAndParseExtraHeaders(existingKey.extraHeaders)
|
||||
: undefined);
|
||||
input.extraHeaders !== undefined
|
||||
? input.extraHeaders
|
||||
: isBaseURLChanged
|
||||
? undefined
|
||||
: existingKey.extraHeaders
|
||||
? decryptAndParseExtraHeaders(existingKey.extraHeaders)
|
||||
: undefined;
|
||||
|
||||
return testLLMConnection({
|
||||
adapter,
|
||||
@@ -519,6 +572,16 @@ export const llmApiKeyRouter = createTRPCRouter({
|
||||
|
||||
// Validate that default credentials sentinel is only allowed for Bedrock/VertexAI in self-hosted deployments
|
||||
const isLangfuseCloud = Boolean(env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION);
|
||||
const isBaseURLChanged =
|
||||
input.baseURL !== undefined
|
||||
? input.baseURL !== existingKey.baseURL
|
||||
: false;
|
||||
|
||||
if (input.baseURL && isBaseURLChanged) {
|
||||
await validateBaseURLForWrite({
|
||||
baseURL: input.baseURL,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.secretKey === BEDROCK_USE_DEFAULT_CREDENTIALS) {
|
||||
if (isLangfuseCloud || input.adapter !== LLMAdapter.Bedrock) {
|
||||
|
||||
@@ -117,7 +117,11 @@ export function ApiKeyList(props: { entityId: string; scope: ApiKeyScope }) {
|
||||
<TableBody className="text-muted-foreground">
|
||||
{apiKeysQuery.data?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center">
|
||||
<TableCell
|
||||
density="comfortable"
|
||||
colSpan={5}
|
||||
className="text-center"
|
||||
>
|
||||
None
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -127,29 +131,32 @@ export function ApiKeyList(props: { entityId: string; scope: ApiKeyScope }) {
|
||||
key={apiKey.id}
|
||||
className="hover:bg-primary-foreground"
|
||||
>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<TableCell
|
||||
density="comfortable"
|
||||
className="hidden md:table-cell"
|
||||
>
|
||||
{apiKey.createdAt.toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell density="comfortable">
|
||||
<ApiKeyNote
|
||||
apiKey={apiKey}
|
||||
entityId={entityId}
|
||||
scope={scope}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
<TableCell density="comfortable" className="font-mono">
|
||||
<CodeView
|
||||
className="inline-block text-xs"
|
||||
content={apiKey.publicKey}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
<TableCell density="comfortable" className="font-mono">
|
||||
{apiKey.displaySecretKey}
|
||||
</TableCell>
|
||||
{/* <TableCell>
|
||||
{apiKey.lastUsedAt?.toLocaleDateString() ?? "Never"}
|
||||
</TableCell> */}
|
||||
<TableCell>
|
||||
<TableCell density="comfortable">
|
||||
<DeleteApiKeyButton
|
||||
entityId={entityId}
|
||||
apiKeyId={apiKey.id}
|
||||
|
||||
@@ -93,7 +93,11 @@ export function LlmApiKeyList(props: { projectId: string }) {
|
||||
<TableBody className="text-muted-foreground">
|
||||
{apiKeys.data?.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center">
|
||||
<TableCell
|
||||
density="comfortable"
|
||||
colSpan={6}
|
||||
className="text-center"
|
||||
>
|
||||
None
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -104,18 +108,28 @@ export function LlmApiKeyList(props: { projectId: string }) {
|
||||
className="hover:bg-primary-foreground cursor-default"
|
||||
onClick={() => setEditingKeyId(apiKey.id)}
|
||||
>
|
||||
<TableCell className="font-mono">{apiKey.provider}</TableCell>
|
||||
<TableCell className="font-mono">{apiKey.adapter}</TableCell>
|
||||
<TableCell className="max-w-md overflow-auto font-mono">
|
||||
<TableCell density="comfortable" className="font-mono">
|
||||
{apiKey.provider}
|
||||
</TableCell>
|
||||
<TableCell density="comfortable" className="font-mono">
|
||||
{apiKey.adapter}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
density="comfortable"
|
||||
className="max-w-md overflow-auto font-mono"
|
||||
>
|
||||
{apiKey.baseURL ?? "default"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
<TableCell density="comfortable" className="font-mono">
|
||||
{apiKey.displaySecretKey}
|
||||
</TableCell>
|
||||
{hasExtraHeaderKeys ? (
|
||||
<TableCell> {apiKey.extraHeaderKeys.join(", ")} </TableCell>
|
||||
<TableCell density="comfortable">
|
||||
{" "}
|
||||
{apiKey.extraHeaderKeys.join(", ")}{" "}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="text-right">
|
||||
<TableCell density="comfortable" className="text-right">
|
||||
<div
|
||||
className="flex justify-end space-x-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { prisma } from "@langfuse/shared/src/db";
|
||||
import {
|
||||
redis,
|
||||
type AuthHeaderValidVerificationResult,
|
||||
type ApiAccessLevel,
|
||||
traceException,
|
||||
logger,
|
||||
} from "@langfuse/shared/src/server";
|
||||
@@ -15,6 +16,9 @@ import { contextWithLangfuseProps } from "@langfuse/shared/src/server";
|
||||
import * as opentelemetry from "@opentelemetry/api";
|
||||
import { env } from "@/src/env.mjs";
|
||||
|
||||
/** Access levels that can be accepted by project-scoped API routes. */
|
||||
type RouteAccessLevel = Exclude<ApiAccessLevel, "organization">;
|
||||
|
||||
type RouteConfig<
|
||||
TQuery extends ZodType<any>,
|
||||
TBody extends ZodType<any>,
|
||||
@@ -40,30 +44,41 @@ type RouteConfig<
|
||||
* @default false
|
||||
*/
|
||||
isAdminApiKeyAuthAllowed?: boolean;
|
||||
/**
|
||||
* Access levels accepted for this route. Defaults to ["project"] (Basic auth only).
|
||||
* Set to ["project", "scores"] to also allow Bearer auth with a public key
|
||||
* (which receives accessLevel "scores").
|
||||
*/
|
||||
allowedAccessLevels?: RouteAccessLevel[];
|
||||
fn: (params: {
|
||||
query: z.infer<TQuery>;
|
||||
body: z.infer<TBody>;
|
||||
req: NextApiRequest;
|
||||
res: NextApiResponse;
|
||||
auth: AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
};
|
||||
}) => Promise<z.infer<TResponse>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies regular API key authentication using ApiAuthService.
|
||||
* Verifies API key authentication (Basic or Bearer) using ApiAuthService.
|
||||
*
|
||||
* This function handles standard project API key authentication with Basic auth.
|
||||
* Returns an auth scope object with project-level access.
|
||||
* Delegates to ApiAuthService.verifyAuthHeaderAndReturnScope which handles
|
||||
* both Basic auth (public + secret key) and Bearer auth (public key only).
|
||||
* The caller controls which access levels are accepted via allowedAccessLevels.
|
||||
*
|
||||
* @param authHeader - The Authorization header from the request
|
||||
* @returns An auth scope object with project-level access
|
||||
* @param allowedAccessLevels - Access levels to accept (default: ["project"])
|
||||
* @returns An auth scope object with the verified access level
|
||||
* @throws Error with appropriate message if authentication fails
|
||||
*/
|
||||
async function verifyBasicAuth(authHeader: string | undefined): Promise<
|
||||
async function verifyApiKeyAuth(
|
||||
authHeader: string | undefined,
|
||||
allowedAccessLevels: RouteAccessLevel[] = ["project"],
|
||||
): Promise<
|
||||
AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
}
|
||||
> {
|
||||
const regularAuth = await new ApiAuthService(
|
||||
@@ -75,10 +90,14 @@ async function verifyBasicAuth(authHeader: string | undefined): Promise<
|
||||
throw { status: 401, message: regularAuth.error };
|
||||
}
|
||||
|
||||
if (regularAuth.scope.accessLevel !== "project") {
|
||||
if (
|
||||
!(allowedAccessLevels as ApiAccessLevel[]).includes(
|
||||
regularAuth.scope.accessLevel,
|
||||
)
|
||||
) {
|
||||
throw {
|
||||
status: 401,
|
||||
message: "Access denied - need to use basic auth with secret key",
|
||||
message: "Access denied - insufficient permissions for this endpoint",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,7 +110,7 @@ async function verifyBasicAuth(authHeader: string | undefined): Promise<
|
||||
}
|
||||
|
||||
return regularAuth as AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,22 +217,25 @@ async function verifyAdminApiKeyAuth(req: NextApiRequest): Promise<
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies authentication for API routes with support for both basic and admin API key auth.
|
||||
* Verifies authentication for API routes with support for both regular API key
|
||||
* auth (Basic or Bearer) and admin API key auth.
|
||||
*
|
||||
* This is the main authentication entry point that delegates to either admin or basic auth
|
||||
* based on the configuration and request headers.
|
||||
* This is the main authentication entry point that delegates to either admin
|
||||
* or regular API key auth based on the configuration and request headers.
|
||||
*
|
||||
* @param req - The Next.js API request
|
||||
* @param isAdminApiKeyAuthAllowed - Whether to allow admin API key authentication
|
||||
* @returns An auth scope object with project-level access
|
||||
* @param allowedAccessLevels - Access levels to accept for regular API key auth
|
||||
* @returns An auth scope object with the verified access level
|
||||
* @throws Error with appropriate status code if authentication fails
|
||||
*/
|
||||
export async function verifyAuth(
|
||||
req: NextApiRequest,
|
||||
isAdminApiKeyAuthAllowed: boolean,
|
||||
allowedAccessLevels: RouteAccessLevel[] = ["project"],
|
||||
): Promise<
|
||||
AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
}
|
||||
> {
|
||||
if (isAdminApiKeyAuthAllowed) {
|
||||
@@ -223,12 +245,15 @@ export async function verifyAuth(
|
||||
// Admin auth succeeded
|
||||
return adminAuth;
|
||||
}
|
||||
// Admin auth not attempted, fall back to basic auth
|
||||
return await verifyBasicAuth(req.headers.authorization);
|
||||
// Admin auth not attempted, fall back to regular API key auth
|
||||
return await verifyApiKeyAuth(
|
||||
req.headers.authorization,
|
||||
allowedAccessLevels,
|
||||
);
|
||||
}
|
||||
|
||||
// Only basic auth is allowed
|
||||
return await verifyBasicAuth(req.headers.authorization);
|
||||
// Only regular API key auth is allowed
|
||||
return await verifyApiKeyAuth(req.headers.authorization, allowedAccessLevels);
|
||||
}
|
||||
|
||||
export const createAuthedProjectAPIRoute = <
|
||||
@@ -240,14 +265,15 @@ export const createAuthedProjectAPIRoute = <
|
||||
): ((req: NextApiRequest, res: NextApiResponse) => Promise<void>) => {
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
let auth: AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
};
|
||||
|
||||
// Verify authentication (basic or admin API key)
|
||||
// Verify authentication (API key or admin API key)
|
||||
try {
|
||||
auth = await verifyAuth(
|
||||
req,
|
||||
routeConfig.isAdminApiKeyAuthAllowed || false,
|
||||
routeConfig.allowedAccessLevels || ["project"],
|
||||
);
|
||||
} catch (error: any) {
|
||||
const statusCode = error.status || 401;
|
||||
@@ -294,7 +320,7 @@ export const createAuthedProjectAPIRoute = <
|
||||
req,
|
||||
res,
|
||||
auth: auth as AuthHeaderValidVerificationResult & {
|
||||
scope: { projectId: string; accessLevel: "project" };
|
||||
scope: { projectId: string; accessLevel: RouteAccessLevel };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { validateAzureContainerName } from "@/src/features/blobstorage-integration/validation";
|
||||
|
||||
/**
|
||||
* Enums
|
||||
@@ -26,7 +27,7 @@ export const CreateBlobStorageIntegrationRequest = z
|
||||
.object({
|
||||
projectId: z.string(),
|
||||
type: BlobStorageIntegrationType,
|
||||
bucketName: z.string(),
|
||||
bucketName: z.string().min(1),
|
||||
endpoint: z.string().nullable().optional(),
|
||||
region: z.string(),
|
||||
accessKeyId: z.string().nullable().optional(),
|
||||
@@ -57,7 +58,8 @@ export const CreateBlobStorageIntegrationRequest = z
|
||||
"exportStartDate is required when exportMode is FROM_CUSTOM_DATE",
|
||||
path: ["exportStartDate"],
|
||||
},
|
||||
);
|
||||
)
|
||||
.superRefine(validateAzureContainerName);
|
||||
|
||||
export const BlobStorageIntegrationResponse = z
|
||||
.object({
|
||||
|
||||
@@ -432,6 +432,7 @@ export function MembersTable({
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
cellPadding="comfortable"
|
||||
/>
|
||||
</SettingsTableCard>
|
||||
) : (
|
||||
@@ -464,6 +465,7 @@ export function MembersTable({
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
cellPadding="comfortable"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"https://staging.langfuse.com/api/public/slack/oauth"
|
||||
],
|
||||
"scopes": {
|
||||
"bot": ["channels:read", "chat:write", "chat:write.public"]
|
||||
"bot": ["channels:read", "groups:read", "chat:write", "chat:write.public"]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { RefreshCw, Search, Hash, Lock } from "lucide-react";
|
||||
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { RefreshCw, Search, Hash, Lock, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { Alert, AlertDescription } from "@/src/components/ui/alert";
|
||||
import { Select, SelectTrigger, SelectValue } from "@/src/components/ui/select";
|
||||
import {
|
||||
Command,
|
||||
@@ -11,23 +16,11 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/src/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/src/components/ui/popover";
|
||||
import { Alert, AlertDescription } from "@/src/components/ui/alert";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { api } from "@/src/utils/api";
|
||||
import { type SlackChannel } from "@langfuse/shared/src/server";
|
||||
|
||||
/**
|
||||
* Represents a Slack channel
|
||||
*/
|
||||
export interface SlackChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isMember: boolean;
|
||||
}
|
||||
export type { SlackChannel };
|
||||
|
||||
/**
|
||||
* Props for the ChannelSelector component
|
||||
@@ -37,6 +30,8 @@ interface ChannelSelectorProps {
|
||||
projectId: string;
|
||||
/** Currently selected channel ID */
|
||||
selectedChannelId?: string;
|
||||
/** Full channel object for display when the ID isn't in the fetched list (e.g. manual entry) */
|
||||
selectedChannel?: SlackChannel | null;
|
||||
/** Callback when a channel is selected */
|
||||
onChannelSelect: (channel: SlackChannel) => void;
|
||||
/** Whether the component is disabled */
|
||||
@@ -51,6 +46,8 @@ interface ChannelSelectorProps {
|
||||
showRefreshButton?: boolean;
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 32;
|
||||
|
||||
/**
|
||||
* A dropdown component for selecting Slack channels with search and filtering capabilities.
|
||||
*
|
||||
@@ -64,9 +61,11 @@ interface ChannelSelectorProps {
|
||||
*
|
||||
* The component uses a command palette style interface for better UX when dealing with
|
||||
* many channels. It supports both keyboard navigation and mouse interaction.
|
||||
* Items are virtualized with @tanstack/react-virtual to handle large channel lists (~5k).
|
||||
*
|
||||
* @param projectId - The project ID for the Slack integration
|
||||
* @param selectedChannelId - Currently selected channel ID
|
||||
* @param selectedChannel - Full channel object for display when the ID isn't in the fetched list (e.g. manual entry)
|
||||
* @param onChannelSelect - Callback when a channel is selected
|
||||
* @param disabled - Whether the component should be disabled
|
||||
* @param placeholder - Placeholder text for the selector
|
||||
@@ -77,6 +76,7 @@ interface ChannelSelectorProps {
|
||||
export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
projectId,
|
||||
selectedChannelId,
|
||||
selectedChannel: selectedChannelProp,
|
||||
onChannelSelect,
|
||||
disabled = false,
|
||||
placeholder = "Select a channel",
|
||||
@@ -87,6 +87,9 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [scrollNode, setScrollNode] = useState<HTMLDivElement | null>(null);
|
||||
const trimmedSearch = searchValue.trim();
|
||||
const effectiveName = trimmedSearch.replace(/^#/, "");
|
||||
|
||||
// Get available channels
|
||||
const {
|
||||
@@ -130,36 +133,63 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (searchValue.trim()) {
|
||||
const searchTerm = searchValue.toLowerCase().trim();
|
||||
if (effectiveName) {
|
||||
const searchTerm = effectiveName.toLowerCase();
|
||||
channels = channels.filter((channel) =>
|
||||
channel.name.toLowerCase().includes(searchTerm),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort channels: public channels first, then private, then by name
|
||||
return channels.sort((a, b) => {
|
||||
return [...channels].sort((a, b) => {
|
||||
if (a.isPrivate !== b.isPrivate) {
|
||||
return a.isPrivate ? 1 : -1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [channelsData?.channels, memberOnly, filterChannels, searchValue]);
|
||||
}, [channelsData?.channels, memberOnly, filterChannels, effectiveName]);
|
||||
|
||||
// Get selected channel info
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredChannels.length,
|
||||
getScrollElement: () => scrollNode,
|
||||
estimateSize: () => ITEM_HEIGHT,
|
||||
overscan: 20,
|
||||
});
|
||||
|
||||
// Get selected channel info — fall back to the prop for manual entries
|
||||
const selectedChannel = useMemo(() => {
|
||||
if (!selectedChannelId || !channelsData?.channels) return null;
|
||||
return channelsData.channels.find(
|
||||
if (!selectedChannelId) return null;
|
||||
const fromList = channelsData?.channels?.find(
|
||||
(channel) => channel.id === selectedChannelId,
|
||||
);
|
||||
}, [selectedChannelId, channelsData?.channels]);
|
||||
return fromList ?? selectedChannelProp ?? null;
|
||||
}, [selectedChannelId, channelsData?.channels, selectedChannelProp]);
|
||||
|
||||
// Handle channel selection
|
||||
const handleChannelSelect = (channel: SlackChannel) => {
|
||||
onChannelSelect(channel);
|
||||
setOpen(false);
|
||||
setSearchValue("");
|
||||
};
|
||||
const selectAndClose = useCallback(
|
||||
(channel: SlackChannel) => {
|
||||
onChannelSelect(channel);
|
||||
setOpen(false);
|
||||
setSearchValue("");
|
||||
},
|
||||
[onChannelSelect],
|
||||
);
|
||||
|
||||
const handleSelectByName = useCallback(() => {
|
||||
const name = searchValue.trim().replace(/^#/, "");
|
||||
if (!name) return;
|
||||
selectAndClose({
|
||||
id: `#${name}`,
|
||||
name,
|
||||
isPrivate: false,
|
||||
isMember: false,
|
||||
});
|
||||
}, [searchValue, selectAndClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollNode) {
|
||||
scrollNode.scrollTop = 0;
|
||||
}
|
||||
}, [effectiveName, scrollNode]);
|
||||
|
||||
// Render channel item
|
||||
const renderChannelItem = (channel: SlackChannel) => (
|
||||
@@ -219,10 +249,23 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const hasExactMatch = filteredChannels.some(
|
||||
(channel) => channel.name.toLowerCase() === effectiveName.toLowerCase(),
|
||||
);
|
||||
const canUseTypedName = effectiveName.length > 0 && !hasExactMatch;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
setOpen(newOpen);
|
||||
if (!newOpen) {
|
||||
setSearchValue("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -246,23 +289,51 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{searchValue
|
||||
? "No channels match your search."
|
||||
: "No channels available."}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredChannels.map((channel) => (
|
||||
<CommandList ref={setScrollNode}>
|
||||
{canUseTypedName && (
|
||||
<CommandGroup className="p-0">
|
||||
<CommandItem
|
||||
key={channel.id}
|
||||
value={channel.id}
|
||||
onSelect={() => handleChannelSelect(channel)}
|
||||
value={`use-${effectiveName}`}
|
||||
onSelect={handleSelectByName}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{renderChannelItem(channel)}
|
||||
<Hash className="text-muted-foreground h-4 w-4" />
|
||||
<span className="flex-1 truncate">
|
||||
Use "{effectiveName}"
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{!canUseTypedName && filteredChannels.length === 0 && (
|
||||
<CommandEmpty>No channels available.</CommandEmpty>
|
||||
)}
|
||||
<CommandGroup
|
||||
className="p-0"
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const channel = filteredChannels[virtualRow.index];
|
||||
return (
|
||||
<CommandItem
|
||||
key={channel.id}
|
||||
value={channel.id}
|
||||
onSelect={() => selectAndClose(channel)}
|
||||
className="cursor-pointer"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: virtualRow.start,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: ITEM_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{renderChannelItem(channel)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -290,6 +361,30 @@ export const ChannelSelector: React.FC<ChannelSelectorProps> = ({
|
||||
{memberOnly && " (member only)"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Private channel scope warning */}
|
||||
{channelsData && !channelsData.hasPrivateChannelAccess && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Private channels are not visible. To access private channels,{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium underline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/api/public/slack/install?projectId=${projectId}`,
|
||||
"slack-reauth",
|
||||
"width=600,height=700",
|
||||
)
|
||||
}
|
||||
>
|
||||
re-authenticate your Slack integration
|
||||
</button>{" "}
|
||||
to grant the required permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,8 +22,12 @@ interface SlackTestMessageButtonProps {
|
||||
size?: ButtonProps["size"];
|
||||
/** Custom button text */
|
||||
buttonText?: string;
|
||||
/** Callback when test message is sent successfully */
|
||||
onSuccess?: () => void;
|
||||
/** Callback when test message is sent successfully, receives the resolved channel info */
|
||||
onSuccess?: (channelInfo: {
|
||||
id: string;
|
||||
name?: string;
|
||||
isPrivate?: boolean;
|
||||
}) => void;
|
||||
/** Callback when test message fails */
|
||||
onError?: (error: Error) => void;
|
||||
/** Whether to show the button text */
|
||||
@@ -52,12 +56,12 @@ export const SlackTestMessageButton: React.FC<SlackTestMessageButtonProps> = ({
|
||||
}) => {
|
||||
// Test message mutation
|
||||
const testMessageMutation = api.slack.sendTestMessage.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
showSuccessToast({
|
||||
title: "Test Message Sent",
|
||||
description: "Test message sent successfully to the selected channel.",
|
||||
});
|
||||
onSuccess?.();
|
||||
onSuccess?.(data.channelInfo);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorToast("Failed to Send Test Message", error.message);
|
||||
@@ -73,7 +77,7 @@ export const SlackTestMessageButton: React.FC<SlackTestMessageButtonProps> = ({
|
||||
await testMessageMutation.mutateAsync({
|
||||
projectId,
|
||||
channelId: selectedChannel.id,
|
||||
channelName: selectedChannel.name,
|
||||
channelName: selectedChannel.name ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
// Error handling is done in the mutation
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import {
|
||||
SlackService,
|
||||
SLACK_BOT_SCOPES,
|
||||
parseSlackInstallationMetadata,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
@@ -30,7 +31,7 @@ export async function handleInstallPath(
|
||||
// 2. Set session cookies for state validation
|
||||
// 3. Render the installation page with "Add to Slack" button
|
||||
const installOptions = {
|
||||
scopes: ["channels:read", "chat:write", "chat:write.public"],
|
||||
scopes: [...SLACK_BOT_SCOPES],
|
||||
metadata: JSON.stringify({ projectId: projectId }),
|
||||
redirectUri: `${env.NEXTAUTH_URL}/api/public/slack/oauth`,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
protectedProjectProcedure,
|
||||
} from "@/src/server/api/trpc";
|
||||
import { z } from "zod";
|
||||
import { SlackService } from "@langfuse/shared/src/server";
|
||||
import { SlackService, SlackApiError } from "@langfuse/shared/src/server";
|
||||
import { throwIfNoProjectAccess } from "@/src/features/rbac/utils/checkProjectAccess";
|
||||
import { logger } from "@langfuse/shared/src/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -112,7 +112,8 @@ export const slackRouter = createTRPCRouter({
|
||||
const client = await slackService.getWebClientForProject(
|
||||
input.projectId,
|
||||
);
|
||||
const channels = await slackService.getChannels(client);
|
||||
const { channels, hasPrivateChannelAccess } =
|
||||
await slackService.getChannels(client);
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
@@ -124,6 +125,7 @@ export const slackRouter = createTRPCRouter({
|
||||
|
||||
return {
|
||||
channels,
|
||||
hasPrivateChannelAccess,
|
||||
teamId: integration.teamId,
|
||||
teamName: integration.teamName,
|
||||
};
|
||||
@@ -201,8 +203,9 @@ export const slackRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
// Slack resolves both channel IDs (C1234) and names (#general)
|
||||
channelId: z.string(),
|
||||
channelName: z.string(),
|
||||
channelName: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -253,7 +256,7 @@ export const slackRouter = createTRPCRouter({
|
||||
},
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*Channel:*\n#${input.channelName}`,
|
||||
text: `*Channel:*\n#${input.channelName ?? input.channelId.replace(/^#/, "")}`,
|
||||
},
|
||||
{
|
||||
type: "mrkdwn",
|
||||
@@ -289,6 +292,30 @@ export const slackRouter = createTRPCRouter({
|
||||
text: "Test message from Langfuse",
|
||||
});
|
||||
|
||||
// For manually-typed channel names (id starts with #), resolve
|
||||
// channel metadata via conversations.info so the UI can show
|
||||
// accurate type/ID info. Skip for channels already selected from
|
||||
// the list since we already have their metadata.
|
||||
let channelInfo: {
|
||||
id: string;
|
||||
name?: string;
|
||||
isPrivate?: boolean;
|
||||
} = { id: result.channel };
|
||||
|
||||
if (input.channelId.startsWith("#")) {
|
||||
const resolved = await SlackService.getInstance().getChannelInfo(
|
||||
client,
|
||||
result.channel,
|
||||
);
|
||||
if (resolved) {
|
||||
channelInfo = {
|
||||
id: resolved.id,
|
||||
name: resolved.name,
|
||||
isPrivate: resolved.isPrivate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog({
|
||||
session: ctx.session,
|
||||
resourceType: "slackIntegration",
|
||||
@@ -296,7 +323,7 @@ export const slackRouter = createTRPCRouter({
|
||||
action: "create",
|
||||
after: {
|
||||
action: "test_message_sent",
|
||||
channelId: input.channelId,
|
||||
channelId: result.channel,
|
||||
channelName: input.channelName,
|
||||
messageTs: result.messageTs,
|
||||
},
|
||||
@@ -304,7 +331,7 @@ export const slackRouter = createTRPCRouter({
|
||||
|
||||
logger.info("Test message sent successfully", {
|
||||
projectId: input.projectId,
|
||||
channelId: input.channelId,
|
||||
channelId: result.channel,
|
||||
channelName: input.channelName,
|
||||
messageTs: result.messageTs,
|
||||
});
|
||||
@@ -313,6 +340,7 @@ export const slackRouter = createTRPCRouter({
|
||||
success: true,
|
||||
messageTs: result.messageTs,
|
||||
channel: result.channel,
|
||||
channelInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to send test message", {
|
||||
@@ -321,10 +349,28 @@ export const slackRouter = createTRPCRouter({
|
||||
channelId: input.channelId,
|
||||
});
|
||||
|
||||
const slackError =
|
||||
error instanceof SlackApiError ? error.slackErrorCode : undefined;
|
||||
|
||||
const userMessage = (() => {
|
||||
switch (slackError) {
|
||||
case "channel_not_found":
|
||||
return 'Channel not found. The channel may not exist or is a private channel the bot has not been invited to. For private channels, invite the app with "/invite @Langfuse" in that channel.';
|
||||
case "not_in_channel":
|
||||
return "The bot is not a member of this channel. Please invite the bot to the channel first.";
|
||||
case "is_archived":
|
||||
return "This channel has been archived and cannot receive messages.";
|
||||
case "invalid_auth":
|
||||
case "token_revoked":
|
||||
return "Slack authentication failed. Please reconnect your Slack workspace.";
|
||||
default:
|
||||
return "Failed to send test message. Please check your Slack connection and channel permissions.";
|
||||
}
|
||||
})();
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Failed to send test message. Please check your Slack connection and channel permissions.",
|
||||
message: userMessage,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -119,19 +119,20 @@ export function SelectWidgetDialog({
|
||||
selectedWidgetId === widget.id ? "bg-muted" : ""
|
||||
}`}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<TableCell density="comfortable" className="font-medium">
|
||||
{widget.name}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
density="comfortable"
|
||||
className="truncate"
|
||||
title={widget.description}
|
||||
>
|
||||
{widget.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell density="comfortable">
|
||||
{startCase(widget.view.toLowerCase())}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell density="comfortable">
|
||||
{getChartTypeDisplayName(
|
||||
widget.chartType as DashboardWidgetChartType,
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { encrypt } from "@langfuse/shared/encryption";
|
||||
import { getDisplaySecretKey } from "@/src/features/llm-api-key/server/router";
|
||||
import { auditLog } from "@/src/features/audit-logs/auditLog";
|
||||
import { InvalidRequestError } from "@langfuse/shared";
|
||||
import { validateLlmConnectionBaseURL } from "@langfuse/shared/src/server";
|
||||
|
||||
export default withMiddlewares({
|
||||
GET: createAuthedProjectAPIRoute({
|
||||
@@ -85,11 +87,21 @@ export default withMiddlewares({
|
||||
provider: body.provider,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
select: { id: true, baseURL: true },
|
||||
});
|
||||
|
||||
const isUpdate = Boolean(existingConnection);
|
||||
|
||||
if (body.baseURL && body.baseURL !== existingConnection?.baseURL) {
|
||||
try {
|
||||
await validateLlmConnectionBaseURL(body.baseURL);
|
||||
} catch (error) {
|
||||
throw new InvalidRequestError(
|
||||
`Invalid baseURL: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const llmConnectionBody = {
|
||||
adapter: body.adapter,
|
||||
secretKey: encrypt(body.secretKey),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
logger,
|
||||
processEventBatch,
|
||||
} from "@langfuse/shared/src/server";
|
||||
import { ForbiddenError } from "@langfuse/shared";
|
||||
import { ScoresApiService } from "@/src/features/public-api/server/scores-api-service";
|
||||
|
||||
export default withMiddlewares({
|
||||
@@ -21,7 +22,14 @@ export default withMiddlewares({
|
||||
name: "Create Score",
|
||||
bodySchema: PostScoresBodyV1,
|
||||
responseSchema: PostScoresResponseV1,
|
||||
allowedAccessLevels: ["project", "scores"],
|
||||
fn: async ({ body, auth, res }) => {
|
||||
if (auth.scope.isIngestionSuspended) {
|
||||
throw new ForbiddenError(
|
||||
"Ingestion suspended: Usage threshold exceeded. Please upgrade your plan.",
|
||||
);
|
||||
}
|
||||
|
||||
const event = {
|
||||
id: v4(),
|
||||
type: eventTypes.SCORE_CREATE,
|
||||
|
||||
@@ -392,7 +392,7 @@ const BlobStorageIntegrationSettingsForm = ({
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{integrationType === "AZURE_BLOB_STORAGE"
|
||||
? "The Azure storage container name"
|
||||
? "Azure container name (3-63 chars, lowercase letters, numbers, and hyphens only)"
|
||||
: "The S3 bucket name"}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
|
||||
@@ -86,6 +86,9 @@ export default function SlackIntegrationSettings() {
|
||||
scope: "automations:CUD",
|
||||
});
|
||||
|
||||
// Channel was typed by name rather than selected from the list
|
||||
const isManualEntry = selectedChannel?.id.startsWith("#") ?? false;
|
||||
|
||||
return (
|
||||
<ContainerPage
|
||||
headerProps={{
|
||||
@@ -121,6 +124,7 @@ export default function SlackIntegrationSettings() {
|
||||
<ChannelSelector
|
||||
projectId={projectId}
|
||||
selectedChannelId={selectedChannel?.id}
|
||||
selectedChannel={selectedChannel}
|
||||
onChannelSelect={setSelectedChannel}
|
||||
placeholder="Choose a channel to test"
|
||||
showRefreshButton={true}
|
||||
@@ -143,15 +147,27 @@ export default function SlackIntegrationSettings() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Channel Type</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedChannel.isPrivate ? "Private" : "Public"}
|
||||
</Badge>
|
||||
{isManualEntry ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Available after sending a test message
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedChannel.isPrivate ? "Private" : "Public"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Channel ID</p>
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
{selectedChannel.id}
|
||||
</p>
|
||||
{isManualEntry ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Available after sending a test message
|
||||
</span>
|
||||
) : (
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
{selectedChannel.id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,6 +178,19 @@ export default function SlackIntegrationSettings() {
|
||||
selectedChannel={selectedChannel}
|
||||
hasAccess={hasAccess}
|
||||
disabled={false}
|
||||
onSuccess={(channelInfo) => {
|
||||
setSelectedChannel((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
id: channelInfo.id,
|
||||
name: channelInfo.name ?? prev.name,
|
||||
isPrivate:
|
||||
channelInfo.isPrivate ?? prev.isPrivate,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,7 +199,11 @@ export default function SlackIntegrationSettings() {
|
||||
{!selectedChannel && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Select a channel above to view its details and test message
|
||||
delivery.
|
||||
delivery. For private channels, invite the app first with{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5">
|
||||
/invite @Langfuse
|
||||
</code>{" "}
|
||||
in that channel.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -180,7 +180,7 @@ function ScoresTab({ userId, projectId }: TabProps) {
|
||||
<ScoresTable
|
||||
projectId={projectId}
|
||||
userId={userId}
|
||||
omittedFilter={["User ID"]}
|
||||
hiddenColumns={["userId"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,3 +93,9 @@ Use root [AGENTS.md](../AGENTS.md) for monorepo-level rules.
|
||||
- Keep tests independent; no ordering assumptions.
|
||||
- Avoid editing `dist/*` directly.
|
||||
- Coordinate shared changes with `../packages/shared`.
|
||||
- Changes to `src/features/blobstorage/` (export pipeline, enrichment logic,
|
||||
field additions, latency unit handling) should be reviewed against the
|
||||
published blob storage docs for consistency — fetch the latest pages and
|
||||
surface any discrepancies:
|
||||
- https://langfuse.com/docs/api-and-data-platform/features/export-to-blob-storage
|
||||
- https://langfuse.com/docs/api-and-data-platform/features/blob-storage-export-fields
|
||||
|
||||
+10
-10
@@ -14,9 +14,9 @@ RUN corepack enable
|
||||
RUN corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine AS runtime-base
|
||||
# Remove package managers from the runtime image; only build stages need them.
|
||||
# package managers and build-only CLIs only increase exposure to CVEs -> remove them
|
||||
RUN rm -rf /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/npm && \
|
||||
rm -f /usr/local/bin/corepack /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/pnpm /usr/local/bin/pnpx /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
rm -f /usr/local/bin/corepack /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/yarn /usr/local/bin/yarnpkg
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} build-base AS pruner
|
||||
|
||||
@@ -50,20 +50,19 @@ RUN turbo run build --filter=worker...
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} builder AS prod-deps
|
||||
|
||||
RUN rm -rf /prod && \
|
||||
pnpm --filter worker deploy --legacy --prod /prod/worker && \
|
||||
# previously we copied the --from=builder /app . (includes full node_modules etc)
|
||||
# we only need the prod + generated prisma client plus .prisma artifacts
|
||||
# @langfuse/shared still pulls in next-auth transitively, so keep the deploy output
|
||||
# intact here instead of pruning node_modules in Docker.
|
||||
# also, pnpm v10 needs the pnpm legacy deploy implementation, we didn't upgrade that yet
|
||||
RUN pnpm --filter worker deploy --legacy --prod /prod/worker && \
|
||||
builder_prisma_client_dir="$(find /app/node_modules/.pnpm -path '*/node_modules/@prisma/client' -type d | head -n 1)" && \
|
||||
deployed_prisma_client_dir="$(find /prod/worker/node_modules/.pnpm -path '*/node_modules/@prisma/client' -type d | head -n 1)" && \
|
||||
builder_prisma_runtime_dir="$(dirname "$(dirname "$builder_prisma_client_dir")")/.prisma" && \
|
||||
deployed_prisma_runtime_dir="$(dirname "$(dirname "$deployed_prisma_client_dir")")/.prisma" && \
|
||||
rm -rf "$deployed_prisma_client_dir" "$deployed_prisma_runtime_dir" && \
|
||||
mkdir -p "$(dirname "$deployed_prisma_client_dir")" && \
|
||||
cp -R "$builder_prisma_client_dir" "$deployed_prisma_client_dir" && \
|
||||
cp -R "$builder_prisma_runtime_dir" "$deployed_prisma_runtime_dir" && \
|
||||
# @langfuse/shared currently brings auth-only Next.js packages that the worker never executes.
|
||||
find /prod/worker/node_modules \
|
||||
\( -path '*/node_modules/next' -o -path '*/node_modules/next-auth' -o -path '*/.bin/next' \) \
|
||||
-exec rm -rf {} +
|
||||
cp -R "$builder_prisma_runtime_dir" "$deployed_prisma_runtime_dir"
|
||||
|
||||
FROM --platform=${TARGETPLATFORM:-linux/amd64} runtime-base AS runner
|
||||
|
||||
@@ -86,6 +85,7 @@ ARG GID=1001
|
||||
RUN addgroup --system --gid ${GID} expressjs
|
||||
RUN adduser --system --uid ${UID} expressjs
|
||||
|
||||
# Copy only production worker payload instead of full builder workspace (just /prod/worker not entire /app)
|
||||
COPY --from=prod-deps --chown=expressjs:expressjs /prod/worker ./worker
|
||||
RUN chmod +x ./worker/entrypoint.sh
|
||||
USER expressjs
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "worker",
|
||||
"version": "3.166.0",
|
||||
"version": "3.167.4",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -58,7 +58,7 @@
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonpath-plus": "10.3.0",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "4.18.1",
|
||||
"p-limit": "^7.3.0",
|
||||
"pg": "^8.13.0",
|
||||
"posthog-node": "^5.8.4",
|
||||
@@ -78,7 +78,7 @@
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260122.3",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"eslint": "^9.39.2",
|
||||
"msw": "^2.6.5",
|
||||
"prettier": "^3.8.1",
|
||||
@@ -86,7 +86,7 @@
|
||||
"tsc-watch": "^6.2.0",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.2",
|
||||
"vitest": "^4.1.4",
|
||||
"wait-for-expect": "^3.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
const streamMock = vi.fn();
|
||||
const chatVertexAIConstructorMock = vi.fn().mockImplementation(() => ({
|
||||
invoke: invokeMock,
|
||||
pipe: vi.fn().mockReturnValue({
|
||||
stream: streamMock,
|
||||
}),
|
||||
}));
|
||||
const VERTEXAI_USE_DEFAULT_CREDENTIALS = "__VERTEXAI_DEFAULT_CREDENTIALS__";
|
||||
|
||||
process.env.CLICKHOUSE_URL ??= "http://localhost:8123";
|
||||
process.env.CLICKHOUSE_USER ??= "default";
|
||||
process.env.CLICKHOUSE_PASSWORD ??= "password";
|
||||
process.env.LANGFUSE_S3_EVENT_UPLOAD_BUCKET ??= "test-bucket";
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
class MockLLMCompletionError extends Error {
|
||||
responseStatusCode: number;
|
||||
isRetryable: boolean;
|
||||
blockReason: null;
|
||||
|
||||
constructor(params: {
|
||||
message: string;
|
||||
responseStatusCode?: number;
|
||||
isRetryable?: boolean;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = "LLMCompletionError";
|
||||
this.responseStatusCode = params.responseStatusCode ?? 500;
|
||||
this.isRetryable = params.isRetryable ?? false;
|
||||
this.blockReason = null;
|
||||
}
|
||||
|
||||
shouldBlockConfig() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getEvaluatorBlockReason() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("fetchLLMCompletion runtime timeouts", () => {
|
||||
let originalTimeout: number;
|
||||
let originalCloudRegion: string | undefined;
|
||||
let env: typeof import("../../../packages/shared/src/env").env;
|
||||
let encrypt: typeof import("../../../packages/shared/src/encryption").encrypt;
|
||||
let fetchLLMCompletion: typeof import("../../../packages/shared/src/server/llm/fetchLLMCompletion").fetchLLMCompletion;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
invokeMock.mockReset();
|
||||
streamMock.mockReset();
|
||||
chatVertexAIConstructorMock.mockClear();
|
||||
vi.resetModules();
|
||||
originalCloudRegion = process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION;
|
||||
delete process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION;
|
||||
vi.doMock("@langchain/google-vertexai", () => ({
|
||||
ChatVertexAI: chatVertexAIConstructorMock,
|
||||
}));
|
||||
vi.doMock("../../../packages/shared/src/server/llm/errors", () => ({
|
||||
LLMCompletionError: MockLLMCompletionError,
|
||||
}));
|
||||
|
||||
({ env } = await import("../../../packages/shared/src/env"));
|
||||
({ encrypt } = await import("../../../packages/shared/src/encryption"));
|
||||
({ fetchLLMCompletion } =
|
||||
await import("../../../packages/shared/src/server/llm/fetchLLMCompletion"));
|
||||
|
||||
originalTimeout = env.LANGFUSE_FETCH_LLM_COMPLETION_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
env.LANGFUSE_FETCH_LLM_COMPLETION_TIMEOUT_MS = originalTimeout;
|
||||
if (originalCloudRegion === undefined) {
|
||||
delete process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION;
|
||||
} else {
|
||||
process.env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = originalCloudRegion;
|
||||
}
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("wraps non-streaming VertexAI timeouts as non-retryable LLMCompletionError", async () => {
|
||||
env.LANGFUSE_FETCH_LLM_COMPLETION_TIMEOUT_MS = 25;
|
||||
|
||||
invokeMock.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const completionPromise = fetchLLMCompletion({
|
||||
streaming: false,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "What is 2+2? Answer only with the number.",
|
||||
type: "public-api-created",
|
||||
},
|
||||
],
|
||||
modelParams: {
|
||||
provider: "google-vertex-ai",
|
||||
adapter: "google-vertex-ai",
|
||||
model: "gemini-2.0-flash",
|
||||
temperature: 0,
|
||||
max_tokens: 10,
|
||||
},
|
||||
llmConnection: {
|
||||
secretKey: encrypt(VERTEXAI_USE_DEFAULT_CREDENTIALS),
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
const completionRejection = expect(completionPromise).rejects.toMatchObject(
|
||||
{
|
||||
name: "LLMCompletionError",
|
||||
message: "Request timed out after 25ms",
|
||||
isRetryable: false,
|
||||
},
|
||||
);
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await completionRejection;
|
||||
});
|
||||
|
||||
it("wraps streaming VertexAI timeouts as non-retryable LLMCompletionError", async () => {
|
||||
env.LANGFUSE_FETCH_LLM_COMPLETION_TIMEOUT_MS = 25;
|
||||
|
||||
streamMock.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const completionPromise = fetchLLMCompletion({
|
||||
streaming: true,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Stream the answer.",
|
||||
type: "public-api-created",
|
||||
},
|
||||
],
|
||||
modelParams: {
|
||||
provider: "google-vertex-ai",
|
||||
adapter: "google-vertex-ai",
|
||||
model: "gemini-2.0-flash",
|
||||
temperature: 0,
|
||||
max_tokens: 10,
|
||||
},
|
||||
llmConnection: {
|
||||
secretKey: encrypt(VERTEXAI_USE_DEFAULT_CREDENTIALS),
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
const completionRejection = expect(completionPromise).rejects.toMatchObject(
|
||||
{
|
||||
name: "LLMCompletionError",
|
||||
message: "Request timed out after 25ms",
|
||||
isRetryable: false,
|
||||
},
|
||||
);
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await completionRejection;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
LangfuseInternalTraceEnvironment,
|
||||
prepareTracedEventsForIngestion,
|
||||
} from "@langfuse/shared/src/server";
|
||||
|
||||
describe("prepareTracedEventsForIngestion", () => {
|
||||
it("sets the configured environment on every forwarded event", () => {
|
||||
const preparedEvents = prepareTracedEventsForIngestion(
|
||||
[
|
||||
{
|
||||
type: "trace-create",
|
||||
body: {
|
||||
id: "trace-123",
|
||||
name: "internal-trace",
|
||||
environment: "wrong-environment",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "span-create",
|
||||
body: {
|
||||
id: "blocked-span",
|
||||
traceId: "trace-123",
|
||||
name: "RunnableLambda",
|
||||
environment: "wrong-environment",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "span-update",
|
||||
body: {
|
||||
id: "blocked-span",
|
||||
traceId: "trace-123",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "generation-create",
|
||||
body: {
|
||||
id: "generation-123",
|
||||
traceId: "trace-123",
|
||||
name: "ChatOpenAI",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "generation-update",
|
||||
body: {
|
||||
id: "generation-123",
|
||||
traceId: "trace-123",
|
||||
output: {
|
||||
role: "assistant",
|
||||
content: "Berlin",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "span-create",
|
||||
body: {
|
||||
id: "allowed-span",
|
||||
traceId: "trace-123",
|
||||
name: "Retriever",
|
||||
environment: "another-environment",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "span-update",
|
||||
body: {
|
||||
id: "allowed-span",
|
||||
traceId: "trace-123",
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
environment: LangfuseInternalTraceEnvironment.PromptExperiments,
|
||||
},
|
||||
);
|
||||
|
||||
expect(preparedEvents.map((event) => event.body.id)).not.toContain(
|
||||
"blocked-span",
|
||||
);
|
||||
expect(preparedEvents).toHaveLength(5);
|
||||
expect(
|
||||
preparedEvents.every(
|
||||
(event) =>
|
||||
event.body.environment ===
|
||||
LangfuseInternalTraceEnvironment.PromptExperiments,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds prompt metadata only to generation-create events", () => {
|
||||
const preparedEvents = prepareTracedEventsForIngestion(
|
||||
[
|
||||
{
|
||||
type: "trace-create",
|
||||
body: {
|
||||
id: "trace-123",
|
||||
name: "internal-trace",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "generation-create",
|
||||
body: {
|
||||
id: "generation-123",
|
||||
traceId: "trace-123",
|
||||
name: "ChatOpenAI",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "generation-update",
|
||||
body: {
|
||||
id: "generation-123",
|
||||
traceId: "trace-123",
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
environment: LangfuseInternalTraceEnvironment.PromptExperiments,
|
||||
prompt: {
|
||||
name: "internal-prompt",
|
||||
version: 3,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(preparedEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "generation-create",
|
||||
body: expect.objectContaining({
|
||||
id: "generation-123",
|
||||
environment: LangfuseInternalTraceEnvironment.PromptExperiments,
|
||||
promptName: "internal-prompt",
|
||||
promptVersion: 3,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "generation-update",
|
||||
body: expect.not.objectContaining({
|
||||
promptName: "internal-prompt",
|
||||
promptVersion: 3,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "trace-create",
|
||||
body: expect.not.objectContaining({
|
||||
promptName: "internal-prompt",
|
||||
promptVersion: 3,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
validateLlmConnectionBaseURL,
|
||||
type LlmBaseUrlValidationWhitelist,
|
||||
} from "../../../packages/shared/src/server/llm/baseUrlValidation";
|
||||
import { env } from "../../../packages/shared/src/env";
|
||||
|
||||
const originalCloudRegion = env.NEXT_PUBLIC_LANGFUSE_CLOUD_REGION;
|
||||
const originalAllowedHosts = env.LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST;
|
||||
const originalAllowedIps = env.LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS;
|
||||
const originalAllowedIpSegments =
|
||||
env.LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS;
|
||||
|
||||
describe("LLM base URL validation", () => {
|
||||
afterEach(() => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = originalCloudRegion;
|
||||
(env as any).LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST =
|
||||
originalAllowedHosts;
|
||||
(env as any).LANGFUSE_LLM_CONNECTION_WHITELISTED_IPS = originalAllowedIps;
|
||||
(env as any).LANGFUSE_LLM_CONNECTION_WHITELISTED_IP_SEGMENTS =
|
||||
originalAllowedIpSegments;
|
||||
});
|
||||
|
||||
it("should reject localhost by default for self-hosted instances", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("http://localhost:11434/v1"),
|
||||
).rejects.toThrow("Blocked hostname detected");
|
||||
});
|
||||
|
||||
it("should allow explicitly allowlisted localhost hosts for self-hosted instances", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
const whitelist: LlmBaseUrlValidationWhitelist = {
|
||||
hosts: ["localhost"],
|
||||
ips: [],
|
||||
ip_ranges: [],
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("http://localhost:11434/v1", whitelist),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should allow explicitly allowlisted IPv6 localhost literals for self-hosted instances", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
const whitelist: LlmBaseUrlValidationWhitelist = {
|
||||
hosts: [],
|
||||
ips: ["::1"],
|
||||
ip_ranges: [],
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("http://[::1]:11434/v1", whitelist),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should allow explicitly allowlisted IPv6 CIDR ranges for self-hosted instances", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
const whitelist: LlmBaseUrlValidationWhitelist = {
|
||||
hosts: [],
|
||||
ips: [],
|
||||
ip_ranges: ["::1/128"],
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("http://[::1]:11434/v1", whitelist),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should ignore self-host allowlists on Langfuse Cloud", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = "US";
|
||||
|
||||
const whitelist: LlmBaseUrlValidationWhitelist = {
|
||||
hosts: ["localhost"],
|
||||
ips: ["127.0.0.1"],
|
||||
ip_ranges: ["127.0.0.0/8"],
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("https://localhost/v1", whitelist),
|
||||
).rejects.toThrow("Blocked hostname detected");
|
||||
});
|
||||
|
||||
it("should allow public HTTPS URLs on Langfuse Cloud", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = "US";
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("https://1.1.1.1/v1"),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should allow unresolved public hostnames by default", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = undefined;
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("https://gateway.invalid/v1"),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should reject non-HTTPS URLs on Langfuse Cloud", async () => {
|
||||
(env as any).NEXT_PUBLIC_LANGFUSE_CLOUD_REGION = "US";
|
||||
|
||||
await expect(
|
||||
validateLlmConnectionBaseURL("http://1.1.1.1/v1"),
|
||||
).rejects.toThrow("Only HTTPS base URLs are allowed on Langfuse Cloud");
|
||||
});
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = "v3.166.0";
|
||||
export const VERSION = "v3.167.4";
|
||||
|
||||
@@ -837,8 +837,9 @@ async function processExperimentBackfill(
|
||||
|
||||
if (datasetRunItems.length === 0) {
|
||||
logger.info(
|
||||
"[EXPERIMENT BACKFILL] No dataset run items to process, skipping",
|
||||
"[EXPERIMENT BACKFILL] No dataset run items to process, advancing cursor",
|
||||
);
|
||||
await updateBackfillTimestamp(upperBound);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
vi.mock("@langfuse/shared/src/server", () => ({
|
||||
convertQueueNameToMetricName: vi.fn().mockImplementation((name) => name),
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
recordDistribution: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@langfuse/shared/src/db", () => ({
|
||||
prisma: {
|
||||
datasetRuns: {
|
||||
findFirstOrThrow: vi.fn(),
|
||||
},
|
||||
jobExecution: {
|
||||
findFirstOrThrow: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("crypto", () => ({
|
||||
randomUUID: vi.fn().mockReturnValue("retry-job-id"),
|
||||
}));
|
||||
|
||||
import { prisma } from "@langfuse/shared/src/db";
|
||||
import { logger, recordDistribution } from "@langfuse/shared/src/server";
|
||||
import { retryLLMRateLimitError } from "./retry-handler";
|
||||
|
||||
describe("retryLLMRateLimitError", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(prisma.jobExecution.findFirstOrThrow as Mock).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns queue_unavailable instead of throwing when queue.add fails", async () => {
|
||||
const add = vi.fn().mockRejectedValue(new Error("redis unavailable"));
|
||||
|
||||
const result = await retryLLMRateLimitError(
|
||||
{
|
||||
data: {
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
projectId: "project-id",
|
||||
jobExecutionId: "job-execution-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
table: "job_executions",
|
||||
idField: "jobExecutionId",
|
||||
queue: { add },
|
||||
queueName: "llm-as-a-judge-execution-queue-1",
|
||||
jobName: "llm-as-a-judge-execution-job",
|
||||
delayFn: () => 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: "queue_unavailable",
|
||||
});
|
||||
expect(add).toHaveBeenCalledOnce();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to enqueue retry job"),
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(recordDistribution).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns queue_unavailable instead of throwing when age lookup fails", async () => {
|
||||
(prisma.jobExecution.findFirstOrThrow as Mock).mockRejectedValue(
|
||||
new Error("database unavailable"),
|
||||
);
|
||||
|
||||
const result = await retryLLMRateLimitError(
|
||||
{
|
||||
data: {
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
projectId: "project-id",
|
||||
jobExecutionId: "job-execution-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
table: "job_executions",
|
||||
idField: "jobExecutionId",
|
||||
queue: { add: vi.fn() },
|
||||
queueName: "llm-as-a-judge-execution-queue-1",
|
||||
jobName: "llm-as-a-judge-execution-job",
|
||||
delayFn: () => 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: "queue_unavailable",
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to handle 429 retry"),
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(recordDistribution).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -28,14 +28,23 @@ interface RetryConfig {
|
||||
delayFn: (attempt: number) => number;
|
||||
}
|
||||
|
||||
export type RetryScheduleResult =
|
||||
| {
|
||||
outcome: "scheduled";
|
||||
delay: number;
|
||||
retryBaggage: RetryBaggage;
|
||||
}
|
||||
| {
|
||||
outcome: "skipped";
|
||||
reason: "too_old";
|
||||
}
|
||||
| {
|
||||
outcome: "queue_unavailable";
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles rate limiting and retry logic for queue jobs
|
||||
* Automatically retries jobs that fail with 429/5xx errors unless they're older than 24h
|
||||
*
|
||||
* @param error - The error that occurred
|
||||
* @param job - The job that failed
|
||||
* @param config - Retry configuration
|
||||
* @returns true if retry was handled and job was added to the queue, false if regular processing should continue
|
||||
*/
|
||||
export async function retryLLMRateLimitError(
|
||||
job: {
|
||||
@@ -46,7 +55,7 @@ export async function retryLLMRateLimitError(
|
||||
};
|
||||
},
|
||||
config: RetryConfig,
|
||||
): Promise<void> {
|
||||
): Promise<RetryScheduleResult> {
|
||||
try {
|
||||
const jobId = job.data.payload[config.idField];
|
||||
|
||||
@@ -67,65 +76,98 @@ export async function retryLLMRateLimitError(
|
||||
`Job ${jobId} is rate limited for more than 24h. Stop retrying.`,
|
||||
);
|
||||
|
||||
return; // Don't retry
|
||||
return {
|
||||
outcome: "skipped",
|
||||
reason: "too_old",
|
||||
};
|
||||
}
|
||||
|
||||
// Retry the job with delay
|
||||
const delay = config.delayFn((job.data.retryBaggage?.attempt ?? 0) + 1);
|
||||
|
||||
const retryBaggage: RetryBaggage | undefined = job.data.retryBaggage
|
||||
const retryBaggage: RetryBaggage = job.data.retryBaggage
|
||||
? {
|
||||
originalJobTimestamp: new Date(
|
||||
job.data.retryBaggage.originalJobTimestamp,
|
||||
),
|
||||
attempt: job.data.retryBaggage.attempt + 1,
|
||||
}
|
||||
: undefined;
|
||||
: {
|
||||
originalJobTimestamp: new Date(job.data.timestamp),
|
||||
attempt: 1,
|
||||
};
|
||||
|
||||
if (!config.queue) {
|
||||
logger.warn(
|
||||
`Retry queue ${config.queueName} is not available for job ${jobId}. Falling back to normal error handling.`,
|
||||
);
|
||||
|
||||
return {
|
||||
outcome: "queue_unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
// Record retry attempt distribution per queue
|
||||
if (retryBaggage) {
|
||||
recordDistribution(
|
||||
`${convertQueueNameToMetricName(config.queueName)}.retries`,
|
||||
retryBaggage.attempt,
|
||||
{
|
||||
queue: config.queueName,
|
||||
},
|
||||
);
|
||||
recordDistribution(
|
||||
`${convertQueueNameToMetricName(config.queueName)}.retries`,
|
||||
retryBaggage.attempt,
|
||||
{
|
||||
queue: config.queueName,
|
||||
},
|
||||
);
|
||||
|
||||
// Record delay distribution per queue
|
||||
recordDistribution(
|
||||
`${convertQueueNameToMetricName(config.queueName)}.total_retry_delay_ms`,
|
||||
new Date().getTime() -
|
||||
new Date(retryBaggage.originalJobTimestamp).getTime(), // this is the total delay
|
||||
{
|
||||
queue: config.queueName,
|
||||
unit: "milliseconds",
|
||||
},
|
||||
);
|
||||
}
|
||||
// Record delay distribution per queue
|
||||
recordDistribution(
|
||||
`${convertQueueNameToMetricName(config.queueName)}.total_retry_delay_ms`,
|
||||
new Date().getTime() -
|
||||
new Date(retryBaggage.originalJobTimestamp).getTime(), // this is the total delay
|
||||
{
|
||||
queue: config.queueName,
|
||||
unit: "milliseconds",
|
||||
},
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Job ${jobId} is rate limited. Retrying in ${delay}ms. Attempt: ${retryBaggage?.attempt}. Total delay: ${retryBaggage ? new Date().getTime() - new Date(retryBaggage?.originalJobTimestamp).getTime() : "unavailable"}ms.`,
|
||||
);
|
||||
|
||||
await config.queue?.add(
|
||||
config.queueName,
|
||||
{
|
||||
name: config.jobName,
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
payload: job.data.payload,
|
||||
retryBaggage: retryBaggage,
|
||||
},
|
||||
{ delay },
|
||||
);
|
||||
try {
|
||||
await config.queue.add(
|
||||
config.queueName,
|
||||
{
|
||||
name: config.jobName,
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
payload: job.data.payload,
|
||||
retryBaggage: retryBaggage,
|
||||
},
|
||||
{ delay },
|
||||
);
|
||||
} catch (addErr) {
|
||||
logger.warn(
|
||||
`Failed to enqueue retry job for ${jobId}. Falling back to normal error handling.`,
|
||||
addErr,
|
||||
);
|
||||
|
||||
return {
|
||||
outcome: "queue_unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: "scheduled",
|
||||
delay,
|
||||
retryBaggage,
|
||||
};
|
||||
} catch (innerErr) {
|
||||
const jobId = job.data.payload[config.idField];
|
||||
logger.error(
|
||||
`Failed to handle 429 retry for ${jobId}. Continuing regular processing.`,
|
||||
`Failed to handle 429 retry for ${jobId}. Falling back to caller error handling.`,
|
||||
innerErr,
|
||||
);
|
||||
|
||||
throw innerErr;
|
||||
return {
|
||||
outcome: "queue_unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
import { Job } from "bullmq";
|
||||
|
||||
vi.mock("@langfuse/shared/src/server", () => ({
|
||||
ExperimentCreateQueue: {
|
||||
getInstance: vi.fn().mockReturnValue({
|
||||
add: vi.fn(),
|
||||
}),
|
||||
},
|
||||
QueueJobs: {
|
||||
ExperimentCreateJob: "experiment-create-job",
|
||||
},
|
||||
QueueName: {
|
||||
ExperimentCreate: "experiment-create-queue",
|
||||
},
|
||||
isLLMCompletionError: vi.fn(),
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
traceException: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../features/utils", () => ({
|
||||
retryLLMRateLimitError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../features/experiments/experimentServiceClickhouse", () => ({
|
||||
createExperimentJobClickhouse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../errors/UnrecoverableError", async () => {
|
||||
const actual = await vi.importActual("../../errors/UnrecoverableError");
|
||||
return {
|
||||
...actual,
|
||||
isUnrecoverableError: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { isLLMCompletionError } from "@langfuse/shared/src/server";
|
||||
import { createExperimentJobClickhouse } from "../../features/experiments/experimentServiceClickhouse";
|
||||
import { retryLLMRateLimitError } from "../../features/utils";
|
||||
import { experimentCreateQueueProcessor } from "../experimentQueue";
|
||||
import { isUnrecoverableError } from "../../errors/UnrecoverableError";
|
||||
|
||||
describe("experimentCreateQueueProcessor", () => {
|
||||
const createMockJob = (): Job<any> =>
|
||||
({
|
||||
data: {
|
||||
payload: {
|
||||
projectId: "project-id",
|
||||
runId: "run-id",
|
||||
},
|
||||
},
|
||||
}) as Job<any>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(isLLMCompletionError as Mock).mockReturnValue(false);
|
||||
(isUnrecoverableError as Mock).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("rethrows retryable LLM errors when the retry queue is unavailable", async () => {
|
||||
const llmError = new Error("Rate limit exceeded");
|
||||
(llmError as Error & { isRetryable: boolean }).isRetryable = true;
|
||||
(createExperimentJobClickhouse as Mock).mockRejectedValue(llmError);
|
||||
(isLLMCompletionError as Mock).mockReturnValue(true);
|
||||
(retryLLMRateLimitError as Mock).mockResolvedValue({
|
||||
outcome: "queue_unavailable",
|
||||
});
|
||||
|
||||
await expect(
|
||||
experimentCreateQueueProcessor(createMockJob()),
|
||||
).rejects.toThrow("Rate limit exceeded");
|
||||
|
||||
expect(retryLLMRateLimitError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
projectId: "project-id",
|
||||
runId: "run-id",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
table: "dataset_runs",
|
||||
idField: "runId",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user