feat(ui): manage admin-owned logging destinations

Adds the destinations table, the scoped-destination add flow, the Edit scope dialog and the Not active badge to Logging and Alerts.

Squashed onto the rebased disclosure branch; the previous history conflicted with staging's changes to the same dashboard files.
This commit is contained in:
Yucheng Zhu 2026-08-07 19:44:50 -07:00
parent b728bba7e1
commit d5580b7a51
26 changed files with 1378 additions and 100 deletions

View file

@ -3135,6 +3135,16 @@
"count": 1
}
},
"src/components/logging_credentials/AccessControlFields.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/logging_credentials/EditLoggingCredentialModal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/logging_settings_view.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -3555,6 +3565,9 @@
"local/no-complex-jsx-arrow": {
"count": 4
},
"max-lines": {
"count": 1
},
"no-nested-ternary": {
"count": 2
},
@ -4310,4 +4323,4 @@
"count": 1
}
}
}
}

View file

@ -5,11 +5,11 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const credentialsKeys = createQueryKeys("credentials");
export const useCredentials = () => {
export const useCredentials = (enabled: boolean = true) => {
const { accessToken } = useAuthorized();
return useQuery<CredentialsResponse>({
queryKey: credentialsKeys.list({}),
queryFn: async () => await credentialListCall(accessToken!),
enabled: Boolean(accessToken),
enabled: enabled && Boolean(accessToken),
});
};

View file

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -97,12 +97,6 @@ describe("LoggingCallbacksTable", () => {
expect(onDelete).toHaveBeenCalledWith(callback);
});
// Regression: `/get_callbacks` returns the same `name` twice when a
// callback is registered for both success and failure (e.g. `generic_api`
// → POST to spend-log on both 200 and 4xx/5xx). The UI used to ignore
// the `type` field and render every row as "Success", masking the
// failure registration. Reading `record.type` fixes the badge AND
// composing the row id with type avoids React's duplicate-key warning.
it("renders distinct Success and Failure badges for same-name dual registration", () => {
render(
<LoggingCallbacksTable
@ -123,4 +117,216 @@ describe("LoggingCallbacksTable", () => {
expect(screen.getByText("Success")).toBeInTheDocument();
expect(screen.getByText("Failure")).toBeInTheDocument();
});
it("renders a global destination's scope", () => {
render(
<LoggingCallbacksTable
callbacks={[
{
name: "langfuse-eu",
variables: baseVars,
credentialName: "langfuse-eu",
access: { global: true },
resolvedScope: { global: true, teams: [], orgs: [] },
},
]}
availableCallbacks={{}}
/>,
);
expect(screen.getByText("Global access")).toBeInTheDocument();
expect(screen.queryByText("Success")).not.toBeInTheDocument();
});
it("renders a scoped destination's resolved teams and orgs", () => {
render(
<LoggingCallbacksTable
callbacks={[
{
name: "arize-eu",
variables: baseVars,
credentialName: "arize-eu",
access: { teams: ["t1", "t2"], orgs: ["o1"] },
resolvedScope: { global: false, teams: ["t1", "t2"], orgs: ["o1"] },
},
]}
availableCallbacks={{}}
/>,
);
expect(screen.getByText("team: t1")).toBeInTheDocument();
expect(screen.getByText("team: t2")).toBeInTheDocument();
expect(screen.getByText("org: o1")).toBeInTheDocument();
});
it("a destination row edits access and deletes without exposing callback actions", async () => {
const user = userEvent.setup();
const onEditAccess = vi.fn();
const onDelete = vi.fn();
const onTest = vi.fn();
const callback = {
name: "dest",
variables: baseVars,
credentialName: "dest",
access: { global: true },
resolvedScope: { global: true, teams: [], orgs: [] },
};
render(
<LoggingCallbacksTable
callbacks={[callback]}
availableCallbacks={{}}
onEditAccess={onEditAccess}
onDelete={onDelete}
onTest={onTest}
/>,
);
await user.click(screen.getByTestId("callback-actions-dest-success"));
expect(screen.queryByTestId("callback-action-test")).not.toBeInTheDocument();
expect(screen.queryByTestId("callback-action-edit")).not.toBeInTheDocument();
await user.click(await screen.findByTestId("destination-action-edit-access"));
expect(onEditAccess).toHaveBeenCalledWith(callback);
await user.click(screen.getByTestId("callback-actions-dest-success"));
await user.click(await screen.findByTestId("destination-action-delete"));
expect(onDelete).toHaveBeenCalledWith(callback);
expect(onTest).not.toHaveBeenCalled();
});
it("a config callback row renders an empty scope", () => {
render(
<LoggingCallbacksTable
callbacks={[{ name: "datadog", type: "success", variables: baseVars }]}
availableCallbacks={{}}
/>,
);
const row = screen.getByText("datadog").closest("tr");
expect(row).not.toBeNull();
expect(within(row as HTMLElement).getByText("—")).toBeInTheDocument();
});
it("gives a destination and a config callback of the same name distinct row ids", () => {
const errors: string[] = [];
const spy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
errors.push(args.map(String).join(" "));
});
render(
<LoggingCallbacksTable
callbacks={[
{ name: "arize", type: "success", variables: baseVars },
{
name: "arize",
variables: baseVars,
credentialName: "arize",
access: { global: true },
resolvedScope: { global: true, teams: [], orgs: [] },
},
]}
availableCallbacks={{}}
/>,
);
expect(errors.filter((e) => /same key/i.test(e))).toHaveLength(0);
spy.mockRestore();
});
});
describe("read-only admin actions", () => {
// Regression: readOnly dropped the whole actions column, so an Admin Viewer lost Test
// on pre-existing callback rows even though that role is backend-authorized for
// /health/services. Only the mutating actions belong behind readOnly.
const row = { name: "langfuse", variables: { ...baseVars }, type: "success" as const };
it("keeps Test and hides the mutating actions for a read-only admin", async () => {
const user = userEvent.setup();
render(<LoggingCallbacksTable callbacks={[row]} readOnly />);
await user.click(screen.getByTestId("callback-actions-langfuse-success"));
expect(await screen.findByTestId("callback-action-test")).toBeInTheDocument();
expect(screen.queryByTestId("callback-action-edit")).toBeNull();
expect(screen.queryByTestId("callback-action-delete")).toBeNull();
});
it("keeps every action for a full admin", async () => {
const user = userEvent.setup();
render(<LoggingCallbacksTable callbacks={[row]} />);
await user.click(screen.getByTestId("callback-actions-langfuse-success"));
expect(await screen.findByTestId("callback-action-test")).toBeInTheDocument();
expect(screen.getByTestId("callback-action-edit")).toBeInTheDocument();
expect(screen.getByTestId("callback-action-delete")).toBeInTheDocument();
});
});
describe("destination rows must not overstate what a destination does", () => {
const destination = (over: Record<string, unknown> = {}) => ({
name: "d1",
variables: baseVars,
credentialName: "d1",
destinationLabel: "Generic OTLP Collector",
resolvedScope: { global: true, teams: [], orgs: [] },
...over,
});
it("shows Not active, never a scope badge, when the backend cannot build the destination", () => {
// Regression: the cell read credential_info.access alone, so a destination the
// resolver excludes (no backend name, or values its adapter rejects) still rendered
// "Global access" and read as live.
render(
<LoggingCallbacksTable
callbacks={[destination({ resolvesToDestination: false }) as never]}
availableCallbacks={{}}
/>,
);
expect(screen.getByText("Not active")).toBeInTheDocument();
expect(screen.queryByText("Global access")).not.toBeInTheDocument();
});
it("still shows the scope badge when the destination does build", () => {
render(
<LoggingCallbacksTable
callbacks={[destination({ resolvesToDestination: true }) as never]}
availableCallbacks={{}}
/>,
);
expect(screen.getByText("Global access")).toBeInTheDocument();
expect(screen.queryByText("Not active")).not.toBeInTheDocument();
});
it("keeps the admin's own name for a destination named after a config callback", () => {
// Regression: the name column applied the callback registry's display label, so a
// destination the admin called "datadog" rendered as "Datadog", indistinguishable
// from the real Datadog callback row.
render(
<LoggingCallbacksTable
callbacks={[destination({ name: "datadog", credentialName: "datadog" }) as never]}
availableCallbacks={{
datadog: { litellm_callback_name: "datadog", litellm_callback_params: [], ui_callback_name: "Datadog" },
}}
/>,
);
expect(screen.getByText("datadog")).toBeInTheDocument();
expect(screen.queryByText("Datadog")).not.toBeInTheDocument();
});
it("renders no actions trigger for a read-only admin, rather than one that opens empty", () => {
// Regression: readOnly suppressed both Edit scope and Delete, and destinations never
// get Test, so the trigger opened a menu with zero items and looked broken.
render(<LoggingCallbacksTable callbacks={[destination() as never]} availableCallbacks={{}} readOnly />);
expect(screen.queryByTestId("callback-actions-d1-success")).not.toBeInTheDocument();
});
it("still renders the actions trigger for a config callback a read-only admin can Test", async () => {
render(
<LoggingCallbacksTable
callbacks={[{ name: "datadog", type: "success_and_failure", variables: baseVars }]}
availableCallbacks={{}}
readOnly
/>,
);
const trigger = screen.getByTestId("callback-actions-datadog-success_and_failure");
await userEvent.click(trigger);
expect(await screen.findByTestId("callback-action-test")).toBeInTheDocument();
});
});

View file

@ -11,6 +11,7 @@ import {
CallbackRow,
callbackRowMode,
getLoggingCallbacksTableColumns,
isDestination,
} from "./LoggingCallbacksTableColumns";
import { AlertingObject } from "./types";
@ -21,7 +22,9 @@ type LoggingCallbacksProps = {
onTest?: (callback: AlertingObject) => void | Promise<void>;
onEdit?: (callback: AlertingObject) => void;
onDelete?: (callback: AlertingObject) => void;
onEditAccess?: (callback: AlertingObject) => void;
onAdd?: () => void;
readOnly?: boolean;
};
function EmptyState() {
@ -45,26 +48,33 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
onTest = () => {},
onEdit = () => {},
onDelete = () => {},
onEditAccess = () => {},
onAdd = () => {},
readOnly = false,
}) => {
const columns = useMemo(() => {
const deps = { availableCallbacks, onTest, onEdit, onDelete };
return getLoggingCallbacksTableColumns(deps);
}, [availableCallbacks, onTest, onEdit, onDelete]);
// A read-only admin keeps the actions column so Test stays reachable -- that role is
// backend-authorized for /health/services. Dropping the column removed it entirely.
return getLoggingCallbacksTableColumns({ availableCallbacks, onTest, onEdit, onDelete, onEditAccess, readOnly });
}, [availableCallbacks, onTest, onEdit, onDelete, onEditAccess, readOnly]);
return (
<div className="mt-4 flex w-full flex-col gap-4">
<h3 className="text-lg font-semibold tracking-tight text-foreground">Active Logging Callbacks</h3>
<div>
<Button onClick={onAdd}>
<Plus />
Add Callback
</Button>
</div>
{!readOnly && (
<div>
<Button onClick={onAdd}>
<Plus />
Add Callback
</Button>
</div>
)}
<DataTable
data={callbacks as CallbackRow[]}
columns={columns}
getRowId={(callback, index) => `${callback.name || index}-${callbackRowMode(callback)}`}
getRowId={(callback, index) =>
`${isDestination(callback) ? "destination" : "callback"}:${callback.name || index}-${callbackRowMode(callback)}`
}
isLoading={isLoading}
loadingMessage="Loading callbacks…"
noDataMessage={<EmptyState />}

View file

@ -4,6 +4,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Play, Trash2 } from "lucide-react";
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
@ -30,6 +31,8 @@ export type AvailableCallbacks = Record<string, AvailableCallbackMeta>;
export const callbackRowMode = (record: CallbackRow): string => record.type || record.mode || "success";
export const isDestination = (record: AlertingObject): boolean => record.credentialName != null;
const CALLBACK_MODE_LABELS: Record<string, string> = {
success: "Success",
failure: "Failure",
@ -42,14 +45,64 @@ function callbackModeTone(mode: string): StatusTone {
return "info";
}
function ScopeCell({ callback }: { callback: AlertingObject }) {
const scope = callback.resolvedScope;
const hasResolvedScope = scope?.global === true || [...(scope?.teams ?? []), ...(scope?.orgs ?? [])].length > 0;
// A grant only routes traces if the credential also builds an exporter. Showing the
// grant alone advertised destinations the resolver excludes, so a misconfigured one
// read as live.
if (callback.resolvesToDestination === false) {
return (
<Badge
variant="outline"
title="This destination cannot be built from its stored values, so it receives no traces"
>
Not active
</Badge>
);
}
if (!scope || !hasResolvedScope) {
return <span className="text-muted-foreground"></span>;
}
if (scope.global) {
return <Badge variant="secondary">Global access</Badge>;
}
const items = [
...scope.teams.map((label) => ({ kind: "team" as const, label })),
...scope.orgs.map((label) => ({ kind: "org" as const, label })),
];
const shown = items.slice(0, 4);
const remainder = items.length - shown.length;
return (
<div className="flex flex-wrap gap-1">
{shown.map((item) => (
<Badge key={`${item.kind}-${item.label}`} variant="outline">
{item.kind}: {item.label}
</Badge>
))}
{remainder > 0 && <Badge variant="secondary">+{remainder} more</Badge>}
</div>
);
}
interface CallbackRowActionsProps {
callback: CallbackRow;
// A read-only admin keeps Test, which the backend authorizes for that role on
// /health/services, and loses everything that mutates.
readOnly?: boolean;
onTest: (callback: AlertingObject) => void | Promise<void>;
onEdit: (callback: AlertingObject) => void;
onDelete: (callback: AlertingObject) => void;
onEditAccess: (callback: AlertingObject) => void;
}
function CallbackRowActions({ callback, onTest, onEdit, onDelete }: CallbackRowActionsProps) {
function CallbackRowActions({ callback, onTest, onEdit, onDelete, onEditAccess, readOnly }: CallbackRowActionsProps) {
const destination = isDestination(callback);
// Destinations get no Test, so a read-only admin has no action left on one. Rendering
// the trigger anyway opened an empty menu that looked broken rather than restricted.
if (destination && readOnly) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger
@ -60,19 +113,40 @@ function CallbackRowActions({ callback, onTest, onEdit, onDelete }: CallbackRowA
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="callback-action-test" onClick={() => void onTest(callback)}>
<Play />
Test
</DropdownMenuItem>
<DropdownMenuItem data-testid="callback-action-edit" onClick={() => onEdit(callback)}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" data-testid="callback-action-delete" onClick={() => onDelete(callback)}>
<Trash2 />
Delete
</DropdownMenuItem>
{destination ? (
!readOnly && (
<DropdownMenuItem data-testid="destination-action-edit-access" onClick={() => onEditAccess(callback)}>
<Pencil />
Edit scope
</DropdownMenuItem>
)
) : (
<>
<DropdownMenuItem data-testid="callback-action-test" onClick={() => void onTest(callback)}>
<Play />
Test
</DropdownMenuItem>
{!readOnly && (
<DropdownMenuItem data-testid="callback-action-edit" onClick={() => onEdit(callback)}>
<Pencil />
Edit
</DropdownMenuItem>
)}
</>
)}
{!readOnly && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
data-testid={destination ? "destination-action-delete" : "callback-action-delete"}
onClick={() => onDelete(callback)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
@ -83,6 +157,8 @@ interface LoggingCallbacksTableColumnsDeps {
onTest: (callback: AlertingObject) => void | Promise<void>;
onEdit: (callback: AlertingObject) => void;
onDelete: (callback: AlertingObject) => void;
onEditAccess: (callback: AlertingObject) => void;
readOnly?: boolean;
}
export const getLoggingCallbacksTableColumns = ({
@ -90,6 +166,8 @@ export const getLoggingCallbacksTableColumns = ({
onTest,
onEdit,
onDelete,
onEditAccess,
readOnly,
}: LoggingCallbacksTableColumnsDeps): ColumnDef<CallbackRow>[] => [
{
id: "name",
@ -99,11 +177,19 @@ export const getLoggingCallbacksTableColumns = ({
enableSorting: false,
cell: ({ row }) => {
const id = row.original.name;
const displayName = availableCallbacks[id]?.ui_callback_name || id;
// A destination keeps the name the admin gave it. Looking it up in the callback
// registry renamed a destination called "datadog" to "Datadog", making it
// indistinguishable in this column from the real Datadog callback row.
const displayName = isDestination(row.original) ? id : availableCallbacks[id]?.ui_callback_name || id;
return (
<span className="block max-w-72 truncate text-sm font-medium" title={displayName}>
{displayName}
</span>
<div>
<span className="block max-w-72 truncate text-sm font-medium" title={displayName}>
{displayName}
</span>
{row.original.destinationLabel && (
<span className="block text-xs text-muted-foreground">{row.original.destinationLabel}</span>
)}
</div>
);
},
},
@ -114,10 +200,26 @@ export const getLoggingCallbacksTableColumns = ({
size: 240,
enableSorting: false,
cell: ({ row }) => {
if (isDestination(row.original)) {
return <span className="text-muted-foreground"></span>;
}
const mode = callbackRowMode(row.original);
return <StatusBadge tone={callbackModeTone(mode)} label={CALLBACK_MODE_LABELS[mode] || mode} />;
},
},
{
id: "access",
meta: { title: "Scope", skeleton: "badge" },
header: "Scope",
size: 280,
enableSorting: false,
cell: ({ row }) =>
isDestination(row.original) ? (
<ScopeCell callback={row.original} />
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
@ -127,7 +229,14 @@ export const getLoggingCallbacksTableColumns = ({
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<CallbackRowActions callback={row.original} onTest={onTest} onEdit={onEdit} onDelete={onDelete} />
<CallbackRowActions
callback={row.original}
onTest={onTest}
onEdit={onEdit}
onDelete={onDelete}
onEditAccess={onEditAccess}
readOnly={readOnly}
/>
</div>
),
},

View file

@ -8,6 +8,36 @@ export interface AlertingObject {
// every row to render as "Success".
type?: "success" | "failure" | "success_and_failure";
variables: AlertingVariables;
// Present only on rows backed by a logging credential (an OTEL trace
// destination). Config-callback rows leave these unset, which is how the table
// tells the two apart.
credentialName?: string;
destinationLabel?: string;
access?: CredentialAccess;
// The destination's whole stored credential_info. PATCH replaces
// credential_info wholesale, so an access edit has to resend all of it.
credentialInfo?: Record<string, unknown>;
// The set of identities that route to this destination, resolved at render
// time from credential_info.access. Display labels only -- ids are not
// surfaced here. global=true bypasses the lists.
resolvedScope?: ResolvedScope;
// Whether the backend can actually build an exporter from this credential, decided
// there by the same function the request-time resolver and the team/org disclosure
// use. Read rather than recomputed: a second implementation of the adapter rules
// here would drift, and the drift is what let a dead destination look active.
resolvesToDestination?: boolean;
}
export interface CredentialAccess {
global?: boolean;
teams?: string[];
orgs?: string[];
}
export interface ResolvedScope {
global: boolean;
teams: string[];
orgs: string[];
}
export interface AlertingVariables {

View file

@ -14,7 +14,7 @@ interface CallbackConfig {
displayName: string;
logo?: string;
supports_key_team_logging: boolean;
dynamic_params: Record<string, "text" | "password" | "select" | "upload" | "number">;
dynamic_params: Record<string, "text" | "password" | "select" | "upload" | "number" | "credential">;
description: string;
}
@ -112,6 +112,15 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
},
description: "Langfuse v3 OTEL Logging Integration",
},
{
id: "weave_otel",
displayName: "Weave OTEL",
// OTEL v2 destination: admin-owned and routed by credential_info.access,
// not configured as a per-team callback here.
supports_key_team_logging: false,
dynamic_params: {},
description: "Weave (W&B) OTEL Logging Integration",
},
{
id: "langsmith",
displayName: "LangSmith",

View file

@ -0,0 +1,64 @@
import { Form, Select, Switch } from "antd";
import React from "react";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types";
interface AccessControlFieldsProps {
// value/onChange are optional so the component can be driven either directly
// (the Add modal) or injected by an antd Form.Item (the Edit modal).
value?: CredentialAccess;
onChange?: (next: CredentialAccess) => void;
}
// Admin-owned access for a logging destination: global (every request) or a set of
// teams/orgs. Per-key targeting is intentionally absent here -- it lives on the key's
// own page, since a key's token rotates on regenerate while team/org ids are stable.
const AccessControlFields: React.FC<AccessControlFieldsProps> = ({ value = {}, onChange = () => {} }) => {
const { data: teams } = useTeams();
const { data: orgs } = useOrganizations();
const isGlobal = value.global === true;
const teamOptions = (teams ?? []).map((t) => ({ value: t.team_id, label: t.team_alias || t.team_id }));
const orgOptions = (orgs ?? []).map((o) => ({
value: o.organization_id,
label: o.organization_alias || o.organization_id,
}));
return (
<>
<Form.Item label="Global" tooltip="Routing scope: traces from every team and org export to this destination.">
<Switch checked={isGlobal} onChange={(global) => onChange({ ...value, global })} />
</Form.Item>
<Form.Item label="Teams" tooltip="Routing scope: only these teams' traffic exports to this destination.">
<Select
mode="multiple"
allowClear
disabled={isGlobal}
placeholder="Select teams"
value={value.teams ?? []}
onChange={(teamIds) => onChange({ ...value, teams: teamIds })}
options={teamOptions}
optionFilterProp="label"
style={{ width: "100%" }}
/>
</Form.Item>
<Form.Item label="Organizations" tooltip="Routing scope: only these orgs' traffic exports to this destination.">
<Select
mode="multiple"
allowClear
disabled={isGlobal}
placeholder="Select organizations"
value={value.orgs ?? []}
onChange={(orgIds) => onChange({ ...value, orgs: orgIds })}
options={orgOptions}
optionFilterProp="label"
style={{ width: "100%" }}
/>
</Form.Item>
</>
);
};
export default AccessControlFields;

View file

@ -0,0 +1,66 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { credentialUpdateCall } from "../networking";
import EditLoggingCredentialModal from "./EditLoggingCredentialModal";
vi.mock("../networking", () => ({
credentialUpdateCall: vi.fn(),
}));
vi.mock("../molecules/notifications_manager", () => ({
default: { success: vi.fn(), fromBackend: vi.fn() },
}));
// The access picker fetches teams/orgs through react-query; this test is about the
// PATCH body the modal builds, so the picker is stubbed out.
vi.mock("./AccessControlFields", () => ({ default: () => null }));
describe("EditLoggingCredentialModal", () => {
const mockUpdate = vi.mocked(credentialUpdateCall);
beforeEach(() => {
vi.clearAllMocks();
mockUpdate.mockResolvedValue({} as never);
});
// Regression: PATCH /credentials replaces credential_info wholesale. Sending only
// { access } dropped credential_type/description, which stops the row being a logging
// destination at all -- it vanishes from resolved_logging_exporters and exports nothing.
it("resends the destination's whole credential_info with only access swapped", async () => {
const user = userEvent.setup();
render(
<EditLoggingCredentialModal
accessToken="tok"
credentialName="dest-1"
access={{ global: false, teams: ["team-a"], orgs: [] }}
credentialInfo={{
credential_type: "logging",
description: "langfuse_otel",
host: "https://collector.internal",
access: { global: false, teams: ["team-a"], orgs: [] },
}}
open
onClose={vi.fn()}
onSaved={vi.fn()}
/>,
);
await user.click(await screen.findByRole("button", { name: /save/i }));
await waitFor(() => expect(mockUpdate).toHaveBeenCalled());
const [, name, body] = mockUpdate.mock.calls[0];
expect(name).toBe("dest-1");
expect(body.credential_name).toBe("dest-1");
// secrets are masked on read, so an access edit must not resend them
expect(body.credential_values).toEqual({});
// every sibling field survives the wholesale replace
expect(body.credential_info).toMatchObject({
credential_type: "logging",
description: "langfuse_otel",
host: "https://collector.internal",
});
expect(body.credential_info.access).toEqual({ global: false, teams: ["team-a"], orgs: [] });
});
});

View file

@ -0,0 +1,79 @@
import { Form, Modal } from "antd";
import React from "react";
import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types";
import NotificationsManager from "../molecules/notifications_manager";
import { credentialUpdateCall } from "../networking";
import AccessControlFields from "./AccessControlFields";
interface EditLoggingCredentialModalProps {
accessToken: string;
credentialName: string | null;
access?: CredentialAccess;
// The destination's stored credential_info. PATCH replaces credential_info
// wholesale, so the whole object is resent with only access swapped; sending
// access alone would drop credential_type/description and stop the row being
// a logging destination at all.
credentialInfo?: Record<string, unknown>;
open: boolean;
onClose: () => void;
onSaved: () => void;
}
interface AccessForm {
access?: CredentialAccess;
}
const EditLoggingCredentialModal: React.FC<EditLoggingCredentialModalProps> = ({
accessToken,
credentialName,
access,
credentialInfo,
open,
onClose,
onSaved,
}) => {
// destroyOnClose remounts the Form each open, so initialValues re-seeds from the
// current destination -- no effect syncing prop into state.
const [form] = Form.useForm<AccessForm>();
const handleSave = async () => {
if (!credentialName) return;
const current = form.getFieldsValue().access ?? {};
// Always send the full access object; a global grant supersedes team/org.
const next: CredentialAccess = current.global
? { global: true, teams: [], orgs: [] }
: { global: false, teams: current.teams ?? [], orgs: current.orgs ?? [] };
try {
await credentialUpdateCall(accessToken, credentialName, {
credential_name: credentialName,
credential_values: {},
credential_info: { ...(credentialInfo ?? {}), access: next },
});
NotificationsManager.success("Access updated");
onSaved();
onClose();
} catch (error) {
NotificationsManager.fromBackend(error instanceof Error ? error.message : String(error));
}
};
return (
<Modal
title={`Edit scope${credentialName ? `${credentialName}` : ""}`}
open={open}
onCancel={onClose}
onOk={handleSave}
okText="Save"
destroyOnClose
>
<Form<AccessForm> form={form} layout="vertical" preserve={false} initialValues={{ access: access ?? {} }}>
<Form.Item name="access" noStyle>
<AccessControlFields />
</Form.Item>
</Form>
</Modal>
);
};
export default EditLoggingCredentialModal;

View file

@ -0,0 +1,39 @@
import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types";
import { credentialCreateCall } from "../networking";
import { LOGGING_DESTINATION_BACKENDS } from "./loggingDestinationFields";
// The OTEL trace-destination backend ids, managed as admin-owned logging destinations
// (credentials). The `langfuse` v2 SDK logger and the generic `otel` callback are
// deliberately not here; they keep their existing global-callback behavior untouched.
export const LOGGING_BACKEND_IDS: ReadonlySet<string> = new Set(LOGGING_DESTINATION_BACKENDS.map((b) => b.id));
// These backends are reachable two ways from the Add modal, so the dropdown carries a
// prefixed option id for the destination branch. Without it a bare `arize` would be
// ambiguous, and picking it would silently create an inert credential instead of the
// proxy-wide callback the same label used to produce.
export const DESTINATION_OPTION_PREFIX = "destination:";
export const backendLabel = (id?: string): string =>
LOGGING_DESTINATION_BACKENDS.find((b) => b.id === id)?.label ?? id ?? "-";
export interface CreateLoggingCredentialInput {
credentialName: string;
backend: string;
values: Record<string, string>;
host?: string;
access?: CredentialAccess;
}
// One place that owns the logging-credential contract: the credential_type tag, the
// backend in description, the non-secret host, and the admin-owned access grant.
export const createLoggingCredential = async (accessToken: string, input: CreateLoggingCredentialInput) =>
credentialCreateCall(accessToken, {
credential_name: input.credentialName,
credential_values: input.values,
credential_info: {
credential_type: "logging",
description: input.backend,
...(input.host ? { host: input.host } : {}),
...(input.access ? { access: input.access } : {}),
},
});

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { LOGGING_DESTINATION_BACKENDS } from "./loggingDestinationFields";
// Arize routes a trace to a project via the model_id / arize.project.name span
// resource attribute, and its OTLP ingestion rejects any span that lacks it
// ("model_id span resource attribute or arize.project.name span attribute is
// required"). The backend reads that project from the credential's
// arize_project_name value, so the create form must collect it as a required
// field; without it every Arize destination created in the UI silently drops
// 100% of its traces.
describe("Arize logging destination fields", () => {
const arize = LOGGING_DESTINATION_BACKENDS.find((b) => b.id === "arize");
it("exposes a required arize_project_name field", () => {
expect(arize).toBeDefined();
const projectField = arize!.fields.find((f) => f.name === "arize_project_name");
expect(projectField).toBeDefined();
expect(projectField!.optional).not.toBe(true);
});
});

View file

@ -0,0 +1,138 @@
// Create-time field shapes for an admin-owned logging destination, keyed by the
// OTEL v2 backend it binds to. This is the inverse of the per-team picker: the
// picker selects a destination by name; these fields are what an admin types when
// CREATING the named destination in the registry. Keeping the raw keys here (the
// admin registry) and out of the per-team form is the provider/logging separation.
export type LoggingFieldType = "text" | "password";
export interface LoggingField {
name: string;
label: string;
type: LoggingFieldType;
optional?: boolean;
// Example value shown as the input placeholder, so an admin knows the format.
placeholder?: string;
}
export interface LoggingDestinationBackend {
id: string; // the callback_name the credential is bound under
label: string;
fields: LoggingField[];
// The non-secret field that names the destination host/endpoint. Surfaced in the
// list so an admin can tell e.g. an EU from a US destination apart.
hostField: string;
}
export const LOGGING_DESTINATION_BACKENDS: LoggingDestinationBackend[] = [
{
id: "langfuse_otel",
label: "Langfuse OTEL",
fields: [
{
name: "langfuse_host",
label: "Langfuse Host",
type: "text",
placeholder: "https://cloud.langfuse.com",
},
{
name: "langfuse_public_key",
label: "Public Key",
type: "password",
placeholder: "pk-lf-00000000-0000-0000-0000-000000000000",
},
{
name: "langfuse_secret_key",
label: "Secret Key",
type: "password",
placeholder: "sk-lf-00000000-0000-0000-0000-000000000000",
},
],
hostField: "langfuse_host",
},
{
id: "arize",
label: "Arize",
fields: [
{
name: "arize_space_id",
label: "Space ID",
type: "password",
placeholder: "U3BhY2U6MTIzNDU6YWJjZA==",
},
{
name: "arize_api_key",
label: "API Key",
type: "password",
placeholder: "ak-0000aaaa-1111-2222-3333-444455556666",
},
{
name: "arize_project_name",
label: "Project Name",
type: "text",
placeholder: "my-llm-app",
},
{
name: "arize_endpoint",
label: "Endpoint (gRPC)",
type: "text",
optional: true,
placeholder: "https://otlp.arize.com/v1",
},
{
name: "arize_http_endpoint",
label: "Endpoint (HTTP)",
type: "text",
optional: true,
placeholder: "http://collector.internal/v1/traces",
},
],
hostField: "arize_endpoint",
},
{
id: "weave_otel",
label: "Weave",
fields: [
{
name: "wandb_api_key",
label: "W&B API Key",
type: "password",
placeholder: "0123456789abcdef0123456789abcdef01234567",
},
{
name: "weave_project_id",
label: "Project (entity/project)",
type: "text",
placeholder: "my-team/my-project",
},
{
name: "weave_endpoint",
label: "Endpoint",
type: "text",
optional: true,
placeholder: "https://trace.wandb.ai",
},
],
hostField: "weave_endpoint",
},
{
id: "generic",
label: "Generic OTLP Collector",
fields: [
{
name: "otel_endpoint",
label: "OTLP Endpoint",
type: "text",
placeholder: "https://collector.example.com:4318/v1/traces",
},
{
name: "otel_headers",
label: "Headers (k=v,k2=v2)",
type: "text",
optional: true,
placeholder: "x-api-key=abc123,x-team=42",
},
],
hostField: "otel_endpoint",
},
];

View file

@ -38,3 +38,25 @@ describe("LoggingSettingsView logos", () => {
expect(screen.getByText("C")).toBeInTheDocument();
});
});
describe("LoggingSettingsView scoped exporters", () => {
it("says nothing about exporters when the caller has not resolved them", () => {
render(<LoggingSettingsView loggingConfigs={[]} disabledCallbacks={[]} />);
expect(screen.queryByText("No logging exporters assigned")).not.toBeInTheDocument();
expect(screen.queryByText("Logging Exporters")).not.toBeInTheDocument();
});
it("reports none only when the caller resolved an empty list", () => {
render(<LoggingSettingsView loggingConfigs={[]} disabledCallbacks={[]} scopedExporters={[]} />);
expect(screen.getByText("No logging exporters assigned")).toBeInTheDocument();
});
it("lists the exporters the caller resolved", () => {
render(<LoggingSettingsView loggingConfigs={[]} disabledCallbacks={[]} scopedExporters={["langfuse-eu"]} />);
expect(screen.getByText("langfuse-eu")).toBeInTheDocument();
expect(screen.queryByText("No logging exporters assigned")).not.toBeInTheDocument();
});
});

View file

@ -13,6 +13,11 @@ interface LoggingConfig {
interface LoggingSettingsViewProps {
loggingConfigs?: LoggingConfig[];
disabledCallbacks?: string[];
// Destinations that route to this identity via credential_info.access
// (teams/orgs/global), resolved server-side. Display only. Left undefined by a
// caller that has not resolved them, which hides the section rather than
// asserting there are none.
scopedExporters?: string[];
variant?: "card" | "inline";
className?: string;
}
@ -20,6 +25,7 @@ interface LoggingSettingsViewProps {
export function LoggingSettingsView({
loggingConfigs = [],
disabledCallbacks = [],
scopedExporters,
variant = "card",
className = "",
}: LoggingSettingsViewProps) {
@ -57,6 +63,30 @@ export function LoggingSettingsView({
const content = (
<div className="space-y-6">
{scopedExporters !== undefined && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<CogIcon className="h-4 w-4 text-blue-600" />
<span className="font-semibold text-gray-900">Logging Exporters</span>
<Tag color="blue">{scopedExporters.length}</Tag>
</div>
{scopedExporters.length > 0 ? (
<div className="flex flex-wrap gap-2">
{scopedExporters.map((name, index) => (
<Tag key={index} color="geekblue">
{name}
</Tag>
))}
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
<CogIcon className="h-4 w-4 text-gray-400" />
<span className="text-gray-500 text-sm">No logging exporters assigned</span>
</div>
)}
</div>
)}
{/* Logging Integrations Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">

View file

@ -636,3 +636,51 @@ describe("getAutoRouterClassifierDefaultPromptCall", () => {
expect(String(mockFetch.mock.calls[1][0])).not.toContain("tier_labels");
});
});
describe("credential path encoding", () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
const mockOk = () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: vi.fn().mockResolvedValue("{}"),
} as any);
global.fetch = mockFetch as any;
return mockFetch;
};
const requestedUrl = (mockFetch: ReturnType<typeof mockOk>): URL => {
const [url] = mockFetch.mock.calls[0];
const urlStr = typeof url === "string" ? url : (url as Request).url;
return new URL(urlStr, "http://example.com");
};
it("encodes the credential name on delete so the request cannot address a different credential", async () => {
const mockFetch = mockOk();
await Networking.credentialDeleteCall("token", "dest/other?force=1");
const parsed = requestedUrl(mockFetch);
expect(parsed.pathname.endsWith("/credentials/dest%2Fother%3Fforce%3D1")).toBe(true);
expect(parsed.search).toBe("");
});
it("encodes the credential name on update so the patch reaches the named credential", async () => {
const mockFetch = mockOk();
await Networking.credentialUpdateCall("token", "dest/other?force=1", { credential_info: {} });
const parsed = requestedUrl(mockFetch);
expect(parsed.pathname.endsWith("/credentials/dest%2Fother%3Fforce%3D1")).toBe(true);
expect(parsed.search).toBe("");
});
});

View file

@ -245,6 +245,7 @@ export interface Organization {
users: any[] | null;
members: any[] | null;
object_permission?: ObjectPermission | null;
resolved_logging_exporters?: string[] | null;
}
export interface CredentialItem {
@ -254,6 +255,19 @@ export interface CredentialItem {
custom_llm_provider?: string;
description?: string;
required?: boolean;
// "logging" tags an admin-owned trace destination (Option A: lives in the
// free-form credential_info, no schema migration). Absent = a provider credential.
credential_type?: string;
// Non-secret destination host/endpoint, surfaced in the logging credentials list.
host?: string;
// Admin-owned access grant for a logging destination: who may see/assign it.
// global reaches everyone; teams/orgs list ids. Visibility only -- on its own it
// never enables tracing for a request.
access?: {
global?: boolean;
teams?: string[];
orgs?: string[];
};
};
}
@ -2627,7 +2641,7 @@ export const credentialGetCall = async (accessToken: string, credentialName: str
export const credentialDeleteCall = async (accessToken: string, credentialName: string) => {
try {
const data = await apiClient.delete(`/credentials/${credentialName}`, { accessToken });
const data = await apiClient.delete(`/credentials/${encodeURIComponent(credentialName)}`, { accessToken });
return data;
// Handle success - you might want to update some state or UI based on the created key
} catch (error) {
@ -2651,7 +2665,7 @@ export const credentialUpdateCall = async (
}
}
const data = await apiClient.patch(`/credentials/${credentialName}`, {
const data = await apiClient.patch(`/credentials/${encodeURIComponent(credentialName)}`, {
accessToken,
body: {
...formValues, // Include formValues in the request body

View file

@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
import { MoneyCell } from "@/components/shared/table_cells";
import CopyButton from "@/components/shared/CopyButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -58,6 +59,11 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
const loggingExporterBadges = useMemo(
() => (orgData?.resolved_logging_exporters ?? []).map((name) => ({ name })),
[orgData],
);
const handleMemberAdd = async (values: any) => {
try {
if (accessToken == null) {
@ -242,6 +248,23 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Logging Exporters</p>
<div className="mt-2 flex flex-wrap gap-2">
{loggingExporterBadges.length > 0 ? (
loggingExporterBadges.map((exporter, index) => (
<Badge key={index} variant="secondary">
{exporter.name}
</Badge>
))
) : (
<span className="text-muted-foreground">None</span>
)}
</div>
</CardContent>
</Card>
<ObjectPermissionsView
objectPermission={orgData.object_permission}
variant="card"
@ -325,6 +348,20 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
</div>
<div>Reset: {orgData.litellm_budget_table.budget_duration || "Never"}</div>
</div>
<div>
<p className="font-medium text-foreground">Logging Exporters</p>
{loggingExporterBadges.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-2">
{loggingExporterBadges.map((exporter, index) => (
<Badge key={index} variant="secondary">
{exporter.name}
</Badge>
))}
</div>
) : (
<div className="text-muted-foreground">None</div>
)}
</div>
<ObjectPermissionsView
objectPermission={orgData.object_permission}

View file

@ -2,9 +2,27 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event";
import { Form } from "antd";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall } from "./networking";
import Settings, { backendCallbackLogoSrc, CallbackSelector } from "./settings";
type SettingsTestProps = {
accessToken: string | null;
userRole: string | null;
userID: string | null;
premiumUser: boolean;
};
// Settings (and its CloudZero cost-tracking child) renders react-query hooks, so
// every render must sit under a QueryClientProvider. Retries off so a failed
// query surfaces immediately instead of hanging the test.
const renderSettings = (props: SettingsTestProps) =>
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<Settings {...props} />
</QueryClientProvider>,
);
vi.mock("./networking", () => ({
getCallbacksCall: vi.fn(),
getCallbackConfigsCall: vi.fn(),
@ -40,6 +58,12 @@ vi.mock("./CloudZeroCostTracking/CloudZeroCostTracking", () => ({
default: () => <div>Mock CloudZero Cost Tracking</div>,
}));
let credentialsFixture: { credentials: unknown[] } = { credentials: [] };
vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({
useCredentials: () => ({ data: credentialsFixture, refetch: vi.fn() }),
}));
// Polyfill ResizeObserver for components relying on it in tests
if (typeof window !== "undefined" && !window.ResizeObserver) {
window.ResizeObserver = class ResizeObserver {
@ -68,7 +92,7 @@ beforeAll(() => {
describe("Settings", () => {
const defaultProps = {
accessToken: "token",
userRole: "admin",
userRole: "Admin",
userID: "user-123",
premiumUser: false,
};
@ -78,6 +102,7 @@ describe("Settings", () => {
beforeEach(() => {
vi.clearAllMocks();
credentialsFixture = { credentials: [] };
mockGetCallbacksCall.mockResolvedValue({
callbacks: [],
available_callbacks: [],
@ -88,7 +113,7 @@ describe("Settings", () => {
});
it("should render the logging callbacks tab when access token is provided", async () => {
const { getByText } = render(<Settings {...defaultProps} />);
const { getByText } = renderSettings(defaultProps);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
@ -96,7 +121,7 @@ describe("Settings", () => {
});
it("should display additional settings tabs", async () => {
const { getByText } = render(<Settings {...defaultProps} />);
const { getByText } = renderSettings(defaultProps);
await waitFor(() => {
expect(getByText("CloudZero Cost Tracking")).toBeInTheDocument();
@ -107,7 +132,7 @@ describe("Settings", () => {
});
it("should load callback configs from the backend when access token is provided", async () => {
render(<Settings {...defaultProps} />);
renderSettings(defaultProps);
await waitFor(() => {
expect(mockGetCallbackConfigsCall).toHaveBeenCalledWith(defaultProps.accessToken);
@ -115,35 +140,30 @@ describe("Settings", () => {
});
it("should display edit modal with fields when edit is clicked", async () => {
// Datadog is a plain config callback that renders a row with the legacy
// Test/Edit/Delete actions. Config-owned OTEL callbacks (arize, langfuse_otel,
// etc.) also render as their own rows now; see the regression test below.
const mockCallback = {
name: "langfuse",
name: "datadog",
variables: {
LANGFUSE_PUBLIC_KEY: "test-public-key",
LANGFUSE_SECRET_KEY: "test-secret-key",
LANGFUSE_HOST: "https://test.langfuse.com",
SLACK_WEBHOOK_URL: null,
OPENMETER_API_KEY: null,
DD_API_KEY: "test-api-key",
DD_SITE: "us5.datadoghq.com",
},
};
const mockCallbackConfig = {
id: "langfuse",
displayName: "Langfuse",
id: "datadog",
displayName: "Datadog",
dynamic_params: {
LANGFUSE_PUBLIC_KEY: {
type: "text",
ui_name: "Public Key",
required: true,
},
LANGFUSE_SECRET_KEY: {
DD_API_KEY: {
type: "password",
ui_name: "Secret Key",
ui_name: "API Key",
required: true,
},
LANGFUSE_HOST: {
DD_SITE: {
type: "text",
ui_name: "Host",
required: false,
ui_name: "Site",
required: true,
},
},
};
@ -151,10 +171,10 @@ describe("Settings", () => {
mockGetCallbacksCall.mockResolvedValue({
callbacks: [mockCallback],
available_callbacks: {
langfuse: {
litellm_callback_name: "langfuse",
litellm_callback_params: ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST"],
ui_callback_name: "Langfuse",
datadog: {
litellm_callback_name: "datadog",
litellm_callback_params: ["DD_API_KEY", "DD_SITE"],
ui_callback_name: "Datadog",
},
},
alerts: [],
@ -163,17 +183,17 @@ describe("Settings", () => {
mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]);
const user = userEvent.setup();
const { getByText } = render(<Settings {...defaultProps} />);
const { getByText } = renderSettings(defaultProps);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
});
await waitFor(() => {
expect(getByText("Langfuse")).toBeInTheDocument();
expect(getByText("Datadog")).toBeInTheDocument();
});
await user.click(screen.getByTestId("callback-actions-langfuse-success"));
await user.click(screen.getByTestId("callback-actions-datadog-success"));
await user.click(await screen.findByTestId("callback-action-edit"));
await waitFor(() => {
@ -181,12 +201,63 @@ describe("Settings", () => {
});
await waitFor(() => {
expect(getByText("Public Key")).toBeInTheDocument();
expect(getByText("Secret Key")).toBeInTheDocument();
expect(getByText("Host")).toBeInTheDocument();
expect(getByText("API Key")).toBeInTheDocument();
expect(getByText("Site")).toBeInTheDocument();
});
});
it("should render a config-owned OTEL callback (langfuse_otel) as its own row", async () => {
// Regression: a proxy-wide langfuse_otel/arize/weave/generic callback configured
// via /config/update must stay visible and manageable in the table. It was being
// filtered out by backend id, removing config-owned rows (not just duplicates).
mockGetCallbacksCall.mockResolvedValue({
callbacks: [{ name: "langfuse_otel", variables: { LANGFUSE_PUBLIC_KEY: "pk", LANGFUSE_SECRET_KEY: "sk" } }],
available_callbacks: {
langfuse_otel: {
litellm_callback_name: "langfuse_otel",
litellm_callback_params: ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"],
ui_callback_name: "Langfuse OTEL",
},
},
alerts: [],
});
mockGetCallbackConfigsCall.mockResolvedValue([
{ id: "langfuse_otel", displayName: "Langfuse OTEL", dynamic_params: {} },
]);
const { getByText } = renderSettings(defaultProps);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
});
await waitFor(() => {
expect(getByText("Langfuse OTEL")).toBeInTheDocument();
});
});
it("should keep rendering every destination when one stored access scope is not a list", async () => {
credentialsFixture = {
credentials: [
{
credential_name: "legacy-shape",
credential_info: { credential_type: "logging", description: "langfuse_otel", access: { teams: "team-1" } },
},
{
credential_name: "well-formed",
credential_info: { credential_type: "logging", description: "langfuse_otel", access: { teams: ["team-2"] } },
},
],
};
const { getByText } = renderSettings(defaultProps);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
});
expect(getByText("legacy-shape")).toBeInTheDocument();
expect(getByText("well-formed")).toBeInTheDocument();
});
it("should hold the callbacks table in loading state until the fetch settles", async () => {
let resolveCallbacks: (value: {
callbacks: never[];
@ -199,7 +270,7 @@ describe("Settings", () => {
}),
);
render(<Settings {...defaultProps} />);
renderSettings(defaultProps);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
@ -214,7 +285,7 @@ describe("Settings", () => {
});
it("should resolve loading without fetching when the user id is missing", async () => {
render(<Settings {...defaultProps} userID={null as unknown as string} />);
renderSettings({ ...defaultProps, userID: null });
await waitFor(() => {
expect(screen.queryByTestId("skeleton-row")).not.toBeInTheDocument();
@ -224,7 +295,7 @@ describe("Settings", () => {
});
it("should display CloudZero Cost Tracking tab", async () => {
const { getByText } = render(<Settings {...defaultProps} />);
const { getByText } = renderSettings(defaultProps);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
@ -274,3 +345,45 @@ describe("CallbackSelector logos", () => {
expect(screen.getByText("N")).toBeInTheDocument();
});
});
describe("Add Callback dropdown", () => {
// Regression: the four OTEL backend ids were filtered out of the config-owned
// callback list and re-added as destinations under the SAME ids, so picking "Arize"
// silently switched from creating a proxy-wide callback (/config/update) to creating
// a by-default-inert logging credential (/credentials). Both paths must be offered,
// and distinguishable, so the pre-existing flow still exists.
const defaultProps = {
accessToken: "token",
userRole: "Admin",
userID: "user-123",
premiumUser: false,
};
it("offers the config-owned OTEL callback and the scoped destination as separate options", async () => {
vi.clearAllMocks();
vi.mocked(alertingSettingsCall).mockResolvedValue([]);
vi.mocked(getCallbacksCall).mockResolvedValue({
callbacks: [],
available_callbacks: {
arize: {
litellm_callback_name: "arize",
litellm_callback_params: ["ARIZE_SPACE_ID", "ARIZE_API_KEY"],
ui_callback_name: "Arize",
},
},
alerts: [],
});
vi.mocked(getCallbackConfigsCall).mockResolvedValue([{ id: "arize", displayName: "Arize", dynamic_params: {} }]);
const { getByText } = renderSettings(defaultProps);
await waitFor(() => {
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
});
fireEvent.click(getByText("Add Callback"));
fireEvent.mouseDown(await screen.findByRole("combobox"));
expect(await screen.findByText("Arize")).toBeInTheDocument();
expect(screen.getByText("Arize (scoped destination)")).toBeInTheDocument();
});
});

View file

@ -30,6 +30,7 @@ import AlertingSettings from "./alerting/alerting_settings";
import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import {
credentialDeleteCall,
deleteCallback,
getCallbackConfigsCall,
getCallbacksCall,
@ -37,7 +38,19 @@ import {
setCallbacksCall,
} from "./networking";
import { LoggingCallbacksTable } from "./Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable";
import { AlertingObject } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types";
import { AlertingObject, CredentialAccess, ResolvedScope } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { canReadCredentialsRole, isProxyAdminRole } from "@/utils/roles";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import EditLoggingCredentialModal from "./logging_credentials/EditLoggingCredentialModal";
import AccessControlFields from "./logging_credentials/AccessControlFields";
import {
backendLabel,
createLoggingCredential,
DESTINATION_OPTION_PREFIX,
} from "./logging_credentials/loggingCredentialApi";
import { LOGGING_DESTINATION_BACKENDS } from "./logging_credentials/loggingDestinationFields";
import { parseErrorMessage } from "./shared/errorUtils";
interface SettingsPageProps {
accessToken: string | null;
@ -236,6 +249,61 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
const [isAddingCallback, setIsAddingCallback] = useState(false);
const [isDeletingCallback, setIsDeletingCallback] = useState(false);
// OTEL trace destinations are proxy-admin-managed credentials tagged
// credential_type=logging; they share the one Active Logging Callbacks table as
// rows alongside config callbacks. Only a proxy admin (or admin-viewer, read-only)
// may read them, so non-admins skip the fetch entirely.
const isProxyAdmin = userRole != null && isProxyAdminRole(userRole);
const { data: credentialData, refetch: refetchCredentials } = useCredentials(canReadCredentialsRole(userRole));
const { data: teamsData } = useTeams();
const { data: orgsData } = useOrganizations();
const [editAccessFor, setEditAccessFor] = useState<{
name: string;
access?: CredentialAccess;
credentialInfo?: Record<string, unknown>;
} | null>(null);
// access for the destination branch of the unified Add modal
const [addAccess, setAddAccess] = useState<CredentialAccess>({});
const addingDestination = selectedCallback != null && selectedCallback.startsWith(DESTINATION_OPTION_PREFIX);
const selectedDestinationBackend = addingDestination
? selectedCallback.slice(DESTINATION_OPTION_PREFIX.length)
: null;
const addingDestinationFields =
LOGGING_DESTINATION_BACKENDS.find((b) => b.id === selectedDestinationBackend)?.fields ?? [];
const teamAlias = (id: string): string => {
const t = (teamsData ?? []).find((team) => team.team_id === id);
return t?.team_alias || id;
};
const orgAlias = (id: string): string => {
const o = (orgsData ?? []).find((org) => org.organization_id === id);
return o?.organization_alias || id;
};
const asIdList = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
const resolveScope = (access?: CredentialAccess): ResolvedScope => ({
global: access?.global === true,
teams: asIdList(access?.teams).map(teamAlias),
orgs: asIdList(access?.orgs).map(orgAlias),
});
const destinationRows: AlertingObject[] = (credentialData?.credentials ?? [])
.filter((c) => c.credential_info?.credential_type === "logging")
.map((c) => ({
name: c.credential_name,
variables: {} as AlertingObject["variables"],
credentialName: c.credential_name,
destinationLabel: c.credential_info?.host
? `${backendLabel(c.credential_info?.description)} · ${c.credential_info.host}`
: backendLabel(c.credential_info?.description),
access: c.credential_info?.access,
credentialInfo: c.credential_info as Record<string, unknown> | undefined,
resolvedScope: resolveScope(c.credential_info?.access),
resolvesToDestination: (c as { resolves_to_destination?: boolean }).resolves_to_destination,
}));
useEffect(() => {
if (!accessToken) {
return;
@ -374,6 +442,34 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
if (!new_callback) {
return;
}
if (new_callback.startsWith(DESTINATION_OPTION_PREFIX) && accessToken) {
const backendId = new_callback.slice(DESTINATION_OPTION_PREFIX.length);
const backendDef = LOGGING_DESTINATION_BACKENDS.find((b) => b.id === backendId);
const fields = backendDef?.fields ?? [];
const values = Object.fromEntries(
fields.filter((f) => formValues[f.name]).map((f) => [f.name, formValues[f.name]]),
);
const host = backendDef ? formValues[backendDef.hostField] : undefined;
const hasAccess = addAccess.global || addAccess.teams?.length || addAccess.orgs?.length;
try {
await createLoggingCredential(accessToken, {
credentialName: formValues.credential_name,
backend: backendId,
values,
host,
access: hasAccess ? addAccess : undefined,
});
NotificationsManager.success("Logging destination created");
refetchCredentials();
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setAddAccess({});
addForm.resetFields();
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
return;
}
await handleCallbackSubmit(formValues, new_callback, false);
};
@ -422,13 +518,22 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
try {
setIsDeletingCallback(true);
await deleteCallback(accessToken, callbackToDelete.name);
NotificationsManager.success(`Callback ${callbackToDelete.name} deleted successfully`);
// Refresh the callbacks list
if (userID && userRole) {
const data = await getCallbacksCall(accessToken, userID, userRole);
setCallbacks(data.callbacks);
// A destination row carries a credentialName; it is a logging credential and is
// deleted (with its stored collector secrets) through the credential endpoint. A
// plain config callback is deleted through the callback endpoint. Both run only
// after the same delete confirmation, so a mis-click can't drop either instantly.
if (callbackToDelete.credentialName) {
await credentialDeleteCall(accessToken, callbackToDelete.credentialName);
NotificationsManager.success("Logging destination deleted");
refetchCredentials();
} else {
await deleteCallback(accessToken, callbackToDelete.name);
NotificationsManager.success(`Callback ${callbackToDelete.name} deleted successfully`);
// Refresh the callbacks list
if (userID && userRole) {
const data = await getCallbacksCall(accessToken, userID, userRole);
setCallbacks(data.callbacks);
}
}
setShowDeleteConfirmModal(false);
@ -459,14 +564,19 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
<TabPanels>
<TabPanel>
<LoggingCallbacksTable
callbacks={callbacks}
callbacks={[...callbacks, ...destinationRows]}
availableCallbacks={allCallbacks}
isLoading={isLoadingCallbacks}
readOnly={!isProxyAdmin}
onAdd={() => setShowAddCallbacksModal(true)}
onEdit={(cb) => {
setSelectedEditCallback(cb);
setShowEditCallback(true);
}}
onEditAccess={(cb) =>
cb.credentialName &&
setEditAccessFor({ name: cb.credentialName, access: cb.access, credentialInfo: cb.credentialInfo })
}
onDelete={(cb) => handleDeleteCallback(cb)}
onTest={async (cb) => {
try {
@ -477,6 +587,17 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
}
}}
/>
{accessToken && (
<EditLoggingCredentialModal
accessToken={accessToken}
credentialName={editAccessFor?.name ?? null}
access={editAccessFor?.access}
credentialInfo={editAccessFor?.credentialInfo}
open={editAccessFor != null}
onClose={() => setEditAccessFor(null)}
onSaved={() => refetchCredentials()}
/>
)}
</TabPanel>
<TabPanel>
<div className="p-8">
@ -585,6 +706,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setSelectedCallbackParams([]);
setAddAccess({});
}}
footer={null}
>
@ -606,16 +728,52 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
labelAlign="left"
>
<CallbackSelector
callbackConfigs={callbackConfigs}
callbackConfigs={[
...callbackConfigs,
...LOGGING_DESTINATION_BACKENDS.map((b) => ({
id: `${DESTINATION_OPTION_PREFIX}${b.id}`,
displayName: `${b.label} (scoped destination)`,
logo: "",
})),
]}
selectedCallback={selectedCallback}
onCallbackChange={handleSelectedCallbackChange}
/>
<DynamicParamsFields
params={selectedCallbackParams}
callbackConfigs={callbackConfigs}
selectedCallback={selectedCallback}
/>
{addingDestination ? (
<div className="space-y-4 mt-6 p-4 bg-gray-50 rounded-lg border">
<FormItem
label={<span className="text-sm font-medium text-gray-700">Name</span>}
name="credential_name"
rules={[{ required: true, message: "Please enter a name" }]}
>
<Input size="large" placeholder="e.g. langfuse-eu" />
</FormItem>
{addingDestinationFields.map((f) => (
<FormItem
key={f.name}
label={<span className="text-sm font-medium text-gray-700">{f.label}</span>}
name={f.name}
rules={
f.optional ? undefined : [{ required: true, message: `Please enter the ${f.label.toLowerCase()}` }]
}
>
{f.type === "password" ? (
<Input.Password size="large" placeholder={f.placeholder} />
) : (
<Input size="large" placeholder={f.placeholder} />
)}
</FormItem>
))}
<AccessControlFields value={addAccess} onChange={setAddAccess} />
</div>
) : (
<DynamicParamsFields
params={selectedCallbackParams}
callbackConfigs={callbackConfigs}
selectedCallback={selectedCallback}
/>
)}
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
<Button2
@ -623,6 +781,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
setShowAddCallbacksModal(false);
setSelectedCallback(null);
setSelectedCallbackParams([]);
setAddAccess({});
addForm.resetFields();
}}
disabled={isAddingCallback}
@ -630,7 +789,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
Cancel
</Button2>
<Button2 htmlType="submit" loading={isAddingCallback} disabled={isAddingCallback}>
{isAddingCallback ? "Adding..." : "Add Callback"}
{isAddingCallback ? "Adding..." : "Add"}
</Button2>
</div>
</Form>
@ -701,13 +860,26 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
<DeleteResourceModal
isOpen={showDeleteConfirmModal}
title="Delete Callback"
message="Are you sure you want to delete this callback? This action cannot be undone."
resourceInformationTitle="Callback Information"
resourceInformation={[
{ label: "Callback Name", value: callbackToDelete?.name },
{ label: "Mode", value: callbackToDelete?.mode || "success" },
]}
title={callbackToDelete?.credentialName ? "Delete Destination" : "Delete Callback"}
message={
callbackToDelete?.credentialName
? "Are you sure you want to delete this trace destination? Its stored collector credentials are deleted with it and traces stop reaching it. This action cannot be undone."
: "Are you sure you want to delete this callback? This action cannot be undone."
}
resourceInformationTitle={callbackToDelete?.credentialName ? "Destination Information" : "Callback Information"}
resourceInformation={
// A destination has no mode. Defaulting the shared field to "success" stated a
// value the row itself renders as "—", so the dialog disagreed with the table.
callbackToDelete?.credentialName
? [
{ label: "Destination Name", value: callbackToDelete?.name },
{ label: "Backend", value: callbackToDelete?.destinationLabel },
]
: [
{ label: "Callback Name", value: callbackToDelete?.name },
{ label: "Mode", value: callbackToDelete?.mode || "success" },
]
}
onCancel={() => {
setShowDeleteConfirmModal(false);
setCallbackToDelete(null);

View file

@ -57,6 +57,7 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeam: vi.fn(),
useTeams: vi.fn().mockReturnValue({ data: [] }),
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
@ -956,6 +957,36 @@ describe("TeamInfoView", () => {
);
});
});
it("omits organization_id from an unrelated save so the org is not re-joined", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ organization_id: "org-123", models: ["gpt-4"] }),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await waitFor(() => {
expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0);
});
await user.click(screen.getByRole("tab", { name: "Settings" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /edit settings/i }));
await waitFor(() => {
expect(screen.getByLabelText("Team Name")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(networking.teamUpdateCall).toHaveBeenCalled();
});
const [, payload] = vi.mocked(networking.teamUpdateCall).mock.calls[0];
expect(payload).not.toHaveProperty("organization_id");
});
});
describe("metadata key-value editing", () => {

View file

@ -150,6 +150,7 @@ export interface TeamData {
access_group_mcp_server_ids?: string[];
access_group_agent_ids?: string[];
access_group_details?: TeamAccessGroupModelGrant[];
resolved_logging_exporters?: string[] | null;
router_settings?: Record<string, any>;
guardrails?: string[];
policies?: string[];
@ -247,6 +248,13 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
);
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam || isTeamAdminFromTeamData;
// Destinations that will receive this team's traces, resolved server-side by
// /team/info from credential_info.access. Names only, visible to every team viewer.
const scopedExportersForTeam = useMemo<string[]>(
() => teamData?.team_info?.resolved_logging_exporters ?? [],
[teamData?.team_info],
);
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
@ -884,6 +892,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<LoggingSettingsView
loggingConfigs={info.metadata?.logging || []}
scopedExporters={scopedExportersForTeam}
disabledCallbacks={[]}
variant="card"
/>
@ -1721,6 +1730,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<LoggingSettingsView
loggingConfigs={info.metadata?.logging || []}
scopedExporters={scopedExportersForTeam}
disabledCallbacks={[]}
variant="inline"
className="pt-4 border-t border-gray-200"

View file

@ -263,6 +263,16 @@ vi.mock("@/app/(dashboard)/hooks/keys/useResetKeySpend", () => ({
}),
}));
// KeyInfoView's Logging Exporters select pulls credentials + orgs via react-query;
// mock both so this QueryClientProvider-free unit test of handleKeyUpdate runs.
vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({
useCredentials: vi.fn().mockReturnValue({ data: { credentials: [] }, refetch: vi.fn() }),
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
useOrganizations: vi.fn().mockReturnValue({ data: [], refetch: vi.fn() }),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useSetKeyBlockedState", () => ({
useSetKeyBlockedState: vi.fn().mockReturnValue({
mutate: vi.fn(),

View file

@ -799,6 +799,7 @@ export function KeyEditView({
<Input value={projectDisplay ?? ""} disabled />
</Form.Item>
)}
<Form.Item label="Logging Settings" name="logging_settings">
<EditLoggingSettings
value={form.getFieldValue("logging_settings")}

View file

@ -83,6 +83,7 @@ export default function KeyInfoView({
const { mutate: setKeyBlockedState, isPending: blockLoading } = useSetKeyBlockedState();
// Add local state to maintain key data and track regeneration
const [currentKeyData, setCurrentKeyData] = useState<KeyResponse | undefined>(keyData);
const [lastRegeneratedAt, setLastRegeneratedAt] = useState<Date | null>(null);
const [isRecentlyRegenerated, setIsRecentlyRegenerated] = useState(false);
const [policyGuardrails, setPolicyGuardrails] = useState<Record<string, string[]>>({});

View file

@ -32,6 +32,11 @@ export const isProxyAdminRole = (role: string): boolean => {
return role === "proxy_admin" || role === "Admin";
};
// Roles allowed to read GET /credentials (proxy admin, plus the read-only admin viewer)
export const canReadCredentialsRole = (role: string | null | undefined): boolean => {
return role != null && (isProxyAdminRole(role) || role === "Admin Viewer" || role === "proxy_admin_viewer");
};
export const isUserTeamAdminForAnyTeam = (teams: Team[] | null, userID: string): boolean => {
if (teams == null) {
return false;