diff --git a/.scripts/agent-integration-tests/helpers.py b/.scripts/agent-integration-tests/helpers.py index 2026c34b..73575cb8 100644 --- a/.scripts/agent-integration-tests/helpers.py +++ b/.scripts/agent-integration-tests/helpers.py @@ -6,6 +6,7 @@ import signal import socket import subprocess +import sys import threading import time from collections import defaultdict @@ -30,10 +31,13 @@ POLL_INTERVAL = 30 # seconds between polls MAX_POLLS = 20 # max number of polls before giving up (10 min for cold starts) QUERY_TIMEOUT = 120 # seconds for HTTP requests -BUNDLE_TIMEOUT = 600 # seconds for bundle deploy/run/destroy commands (10 min for parallel runs) +BUNDLE_TIMEOUT = ( + 600 # seconds for bundle deploy/run/destroy commands (10 min for parallel runs) +) QUICKSTART_TIMEOUT = 600 # seconds for quickstart command (10 min for parallel runs) EVALUATE_TIMEOUT = 900 # seconds for agent-evaluate SERVER_START_TIMEOUT = 600 # seconds to wait for local server to start (accommodates cold CI runners + heavy template imports) +EXPERIMENT_ACCESS_MAX_ATTEMPTS = 6 # --------------------------------------------------------------------------- # Logging & subprocess @@ -114,7 +118,9 @@ def _gh_endgroup() -> None: print("::endgroup::") -def _run_cmd(cmd: list[str], *, verbose: bool = False, **kwargs) -> subprocess.CompletedProcess: +def _run_cmd( + cmd: list[str], *, verbose: bool = False, **kwargs +) -> subprocess.CompletedProcess: """Run a subprocess and return the result. Logging behaviour: @@ -154,7 +160,9 @@ def _run_cmd(cmd: list[str], *, verbose: bool = False, **kwargs) -> subprocess.C print(f"[{_ts()}] ✓ {short_cmd} ({_fmt_duration(duration)})") else: marker = "✓" if result.returncode == 0 else "✗" - print(f"[{_ts()}] {marker} {cmd_str} (exit {result.returncode}, {_fmt_duration(duration)})") + print( + f"[{_ts()}] {marker} {cmd_str} (exit {result.returncode}, {_fmt_duration(duration)})" + ) if result.stdout: print(f" stdout:\n{result.stdout.rstrip()}") if result.stderr: @@ -182,12 +190,20 @@ def _run_with_retries( result = _run_cmd(cmd, cwd=cwd, timeout=timeout) except subprocess.TimeoutExpired: _log(f" timed out after {timeout}s") - if attempt < max_attempts and recover and recover(f"timed out after {timeout}s", attempt, max_attempts): + if ( + attempt < max_attempts + and recover + and recover(f"timed out after {timeout}s", attempt, max_attempts) + ): continue raise if result.returncode == 0: return result - if attempt < max_attempts and recover and recover(result.stderr, attempt, max_attempts): + if ( + attempt < max_attempts + and recover + and recover(result.stderr, attempt, max_attempts) + ): continue break assert result.returncode == 0, ( @@ -215,7 +231,9 @@ def copy_template(template_dir: Path, app_name_suffix: str = "-p") -> Path: shutil.copytree( template_dir, tmp_dir, - ignore=shutil.ignore_patterns(".venv", ".bundle", ".databricks", ".env", "__pycache__", "*.pyc"), + ignore=shutil.ignore_patterns( + ".venv", ".bundle", ".databricks", ".env", "__pycache__", "*.pyc" + ), ) yml_path = tmp_dir / "databricks.yml" @@ -280,13 +298,17 @@ def uv_sync(template_dir: Path, max_attempts: int = 3): if result.returncode == 0: return if attempt < max_attempts: - _log(f" uv sync attempt {attempt}/{max_attempts} failed, retrying in 10s...") + _log( + f" uv sync attempt {attempt}/{max_attempts} failed, retrying in 10s..." + ) time.sleep(10) - _log(f" uv sync failed online; falling back to UV_OFFLINE=true (cache-only)...") + _log(" uv sync failed online; falling back to UV_OFFLINE=true (cache-only)...") env = os.environ.copy() env["UV_OFFLINE"] = "true" - result = _run_cmd(["uv", "sync"], cwd=template_dir, timeout=QUICKSTART_TIMEOUT, env=env) + result = _run_cmd( + ["uv", "sync"], cwd=template_dir, timeout=QUICKSTART_TIMEOUT, env=env + ) assert result.returncode == 0, ( f"uv sync failed in {template_dir.name}:\n" f"stdout: {result.stdout}\n" @@ -352,8 +374,9 @@ def run_quickstart( return result - -def git_copy_template(template_name: str, dest: Path, git_ref: str | None = None) -> Path: +def git_copy_template( + template_name: str, dest: Path, git_ref: str | None = None +) -> Path: """Copy a template directory to dest using git-tracked files only. Without git_ref: uses `git ls-files` so uncommitted modifications to tracked @@ -415,7 +438,16 @@ def databricks_create_app(app_name: str, profile: str): 'compute is in STARTING state' error when bundle deploy tries to update it. """ result = _run_cmd( - ["databricks", "apps", "create", app_name, "-p", profile, "--no-compute", "--no-wait"], + [ + "databricks", + "apps", + "create", + app_name, + "-p", + profile, + "--no-compute", + "--no-wait", + ], timeout=60, ) assert result.returncode == 0, f"Failed to create app {app_name}: {result.stderr}" @@ -518,7 +550,9 @@ def _start_server_once(template_dir: Path, port: int) -> tuple[subprocess.Popen, raise TimeoutError(f"Server did not start within {SERVER_START_TIMEOUT} seconds") -def start_server(template_dir: Path, port: int = 0, max_attempts: int = 2) -> tuple[subprocess.Popen, int]: +def start_server( + template_dir: Path, port: int = 0, max_attempts: int = 2 +) -> tuple[subprocess.Popen, int]: """Start `uv run start-server` as a background process, with one retry. If port is 0, dynamically allocates a free port. Watches stderr for @@ -548,7 +582,9 @@ def start_server(template_dir: Path, port: int = 0, max_attempts: int = 2) -> tu ) # fall through to next iteration — allocates a new port, # spawns a new subprocess. - raise RuntimeError("start_server exited the retry loop without a result") # unreachable + raise RuntimeError( + "start_server exited the retry loop without a result" + ) # unreachable def stop_server(proc: subprocess.Popen): @@ -625,7 +661,9 @@ def query_endpoint( f"Expected text/event-stream, got {content_type}" ) has_data = any( - line.startswith("data:") for line in resp.iter_lines(decode_unicode=True) if line + line.startswith("data:") + for line in resp.iter_lines(decode_unicode=True) + if line ) _log(f" streaming: content_type={content_type}, has_data={has_data}") assert has_data, "No SSE data: events received in stream response" @@ -691,6 +729,364 @@ def query_with_openai_sdk( return output_text +def run_local_trace_test( + template, manifest_path: Path, failure_manifest_path: Path | None = None +): + """Run a real deterministic suite and require success plus injected failure.""" + from template_config import REPO_ROOT + + conformance_dir = REPO_ROOT / ".scripts" / "trace-conformance" + sys.path.insert(0, str(conformance_dir)) + from normalize import load_trace_manifest + + command = list(template.local_test_command or ()) + assert command, f"{template.name} has no deterministic trace test command" + env = os.environ.copy() + env.update( + { + "TRACE_CONFORMANCE_MANIFEST": str(manifest_path), + "TRACE_CONFORMANCE_TEMPLATE": template.name, + } + ) + if failure_manifest_path is not None: + env["TRACE_CONFORMANCE_FAILURE_MANIFEST"] = str(failure_manifest_path) + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = os.pathsep.join( + value for value in (str(conformance_dir), existing_pythonpath) if value + ) + if command[0] == "__appkit_generated_owner__": + owner_root = Path(command[1]) + command = [ + "npx", + "--yes", + "pnpm@10.21.0", + "exec", + "vitest", + "run", + "packages/appkit/src/plugins/agents/tests/trace-conformance.integration.test.ts", + ] + env.update( + { + "APPKIT_TRACE_CONFORMANCE_CANDIDATE": template.name, + "APPKIT_TRACE_CONFORMANCE_SOURCE_DIRECTORY": str(template.path), + } + ) + cwd = owner_root + elif command[0] == "uv": + env["PYTEST_PLUGINS"] = "normalize" + cwd = REPO_ROOT + else: + cwd = template.path + result = _run_cmd(command, cwd=cwd, env=env, timeout=EVALUATE_TIMEOUT, verbose=True) + assert result.returncode == 0, ( + f"deterministic trace test failed for {template.name}:\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + if command[0] not in {"uv", "npx"} and ( + not manifest_path.exists() + or (failure_manifest_path is not None and not failure_manifest_path.exists()) + ): + _run_typescript_trace_probe( + template.path, + manifest_path, + conformance_dir, + failure_manifest_path, + ) + assert manifest_path.exists(), ( + f"{template.name} deterministic trace test did not write {manifest_path}" + ) + success = load_trace_manifest(manifest_path) + if failure_manifest_path is None: + return success + assert failure_manifest_path.exists(), ( + f"{template.name} deterministic trace test did not write an " + f"injected-failure manifest at {failure_manifest_path}" + ) + return success, load_trace_manifest(failure_manifest_path) + + +def _run_typescript_trace_probe( + template_dir: Path, + manifest_path: Path, + conformance_dir: Path, + failure_manifest_path: Path | None = None, +) -> None: + """Run the real @mlflow/core callback against a deterministic fake exporter.""" + from contract import assert_trace_contract + from normalize import normalize_mlflow_core_trace, write_trace_manifest + + raw_path = manifest_path.with_suffix(".raw.json") + artifact_dir = manifest_path.with_suffix(".artifacts") + probe_path = template_dir / "tests" / ".trace-conformance.generated.test.ts" + probe_path.write_text( + """import fs from "node:fs"; +import http from "node:http"; +import { pathToFileURL } from "node:url"; +import { test } from "@jest/globals"; +import * as mlflow from "@mlflow/core"; +import { createLangChainTracingCallback, withAgentRequestTrace } from "../src/framework/tracing.js"; + +test("writes a real production callback manifest", async () => { + const spans: any[] = []; + const traceMetadata = new Map>(); + const server = http.createServer((request, response) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { body += chunk; }); + request.on("end", () => { + const payload = JSON.parse(body); + const traceInfo = payload.trace.trace_info; + traceInfo.tags = { + ...(traceInfo.tags ?? {}), + "mlflow.artifactLocation": pathToFileURL( + process.env.TRACE_CONFORMANCE_ARTIFACTS!, + ).href, + }; + traceMetadata.set(traceInfo.trace_id, traceInfo.trace_metadata ?? {}); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ trace: { trace_info: traceInfo } })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("loopback MLflow exporter did not bind a TCP port"); + } + mlflow.registerOnSpanEndHook((span) => spans.push(span)); + mlflow.init({ + trackingUri: `http://127.0.0.1:${address.port}`, + experimentId: "1", + }); + try { + await withAgentRequestTrace( + { messages: [{ role: "user", content: "Use the clock tool" }] }, + { sessionId: "session-1", userId: "user-1", requestId: "request-1" }, + async (request) => { + const callback = createLangChainTracingCallback(); + callback.handleChatModelStart( + { id: ["ChatDatabricks"] }, + [[{ role: "user", content: "Use the clock tool" }]], + "model-run", + undefined, + { invocation_params: { model: "test-model", provider: "databricks" } }, + [], + { ls_provider: "databricks" }, + ); + callback.handleLLMNewToken("tool", undefined, "model-run"); + callback.handleLLMEnd( + { + generations: [[{ message: { content: "done", usage_metadata: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, response_metadata: { finish_reason: "stop" } } }]], + llmOutput: {}, + }, + "model-run", + ); + callback.handleToolStart({ id: ["clock"] }, JSON.stringify({ zone: "UTC" }), "tool-run"); + callback.handleToolEnd({ time: "12:00" }, "tool-run"); + request.setOutputs({ text: "done" }); + return { text: "done" }; + }, + ); + try { + await withAgentRequestTrace( + { messages: [{ role: "user", content: "INJECT_TRACE_FAILURE" }] }, + { sessionId: "session-failure", userId: "user-1", requestId: "request-failure" }, + async () => { + const callback = createLangChainTracingCallback(); + callback.handleChatModelStart( + { id: ["ChatDatabricks"] }, + [[{ role: "user", content: "INJECT_TRACE_FAILURE" }]], + "failed-model-run", + undefined, + { invocation_params: { model: "test-model", provider: "databricks" } }, + [], + { ls_provider: "databricks" }, + ); + callback.handleLLMNewToken("partial", undefined, "failed-model-run"); + callback.handleLLMError(new Error("injected model failure"), "failed-model-run"); + throw new Error("injected request failure"); + }, + ); + } catch { + // The trace is the product under test; the injected operation must fail. + } + await mlflow.flushTraces(); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + } + const normalizedSpans = spans.map((span: any) => ({ + traceId: span.traceId, + spanId: span.spanId, + parentSpanId: span.parentId, + name: span.name, + spanType: span.spanType, + inputs: span.inputs, + outputs: span.outputs, + status: { code: span.status.statusCode }, + latencyMs: Math.max( + 0, + (span.endTime[0] - span.startTime[0]) * 1000 + + (span.endTime[1] - span.startTime[1]) / 1_000_000, + ), + links: [], + attributes: { + ...span.attributes, + ...(span.parentId === null ? traceMetadata.get(span.traceId) ?? {} : {}), + }, + })); + fs.writeFileSync( + process.env.TRACE_CONFORMANCE_RAW!, + JSON.stringify({ spans: normalizedSpans }), + ); +}); +""" + ) + try: + result = _run_cmd( + [ + "npm", + "test", + "--", + "--runInBand", + "tests/.trace-conformance.generated.test.ts", + ], + cwd=template_dir, + env={ + **os.environ, + "TRACE_CONFORMANCE_RAW": str(raw_path), + "TRACE_CONFORMANCE_ARTIFACTS": str(artifact_dir), + }, + timeout=EVALUATE_TIMEOUT, + verbose=True, + ) + assert result.returncode == 0, ( + f"TypeScript trace probe failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + raw = json.loads(raw_path.read_text()) + trace_ids = list(dict.fromkeys(span["traceId"] for span in raw["spans"])) + manifests = [] + for trace_id in trace_ids: + trace_raw = { + "info": {"traceId": trace_id}, + "data": { + "spans": [ + span for span in raw["spans"] if span["traceId"] == trace_id + ] + }, + } + manifest = normalize_mlflow_core_trace(template_dir.name, trace_raw) + assert_trace_contract(manifest) + manifests.append(manifest) + success = next( + manifest + for manifest in manifests + if not any(span.status == "ERROR" for span in manifest.spans) + ) + write_trace_manifest(manifest_path, success) + if failure_manifest_path is not None: + failure = next( + manifest + for manifest in manifests + if any(span.status == "ERROR" for span in manifest.spans) + ) + write_trace_manifest(failure_manifest_path, failure) + finally: + probe_path.unlink(missing_ok=True) + raw_path.unlink(missing_ok=True) + shutil.rmtree(artifact_dir, ignore_errors=True) + + +def execute_trace_row_query( + workspace, + warehouse_id: str, + otel_spans_table: str, + trace_id: str, +) -> list[dict[str, object]]: + """Query persisted OTel spans with named SQL parameters.""" + from databricks.sdk.service.sql import StatementParameterListItem + + statement = ( + "SELECT trace_id, span_id, parent_span_id, name, attributes\n" + "FROM IDENTIFIER(:otel_spans_table)\n" + "WHERE trace_id = :trace_id\n" + "ORDER BY start_time_unix_nano" + ) + response = workspace.statement_execution.execute_statement( + statement=statement, + warehouse_id=warehouse_id, + parameters=[ + StatementParameterListItem( + name="otel_spans_table", type="STRING", value=otel_spans_table + ), + StatementParameterListItem(name="trace_id", type="STRING", value=trace_id), + ], + wait_timeout="50s", + ) + for _ in range(120): + status = getattr(response, "status", None) + state = getattr(getattr(status, "state", None), "value", None) + if state == "SUCCEEDED": + values = ( + getattr(getattr(response, "result", None), "data_array", None) or [] + ) + return [ + dict( + zip( + ("trace_id", "span_id", "parent_span_id", "name", "attributes"), + row, + ) + ) + for row in values + ] + if state in {"FAILED", "CANCELED", "CLOSED"}: + error = getattr(status, "error", None) + raise RuntimeError( + f"UC trace row query {state.lower()}: " + f"{getattr(error, 'message', None) or 'unknown error'}" + ) + statement_id = getattr(response, "statement_id", None) + if not statement_id: + raise RuntimeError( + f"UC trace row query is {state!r} without a statement ID" + ) + response = workspace.statement_execution.get_statement(statement_id) + time.sleep(1) + raise TimeoutError("UC trace row query did not finish after 120 polls") + + +def poll_trace_rows( + workspace, + warehouse_id: str, + otel_spans_table: str, + trace_id: str, + *, + max_attempts: int = 36, + interval_seconds: float = 5.0, + sleep=time.sleep, +) -> list[dict[str, object]]: + """Wait for the exact current-run trace to become visible in UC.""" + for attempt in range(max_attempts): + rows = execute_trace_row_query( + workspace, warehouse_id, otel_spans_table, trace_id + ) + if rows: + foreign = [ + row.get("trace_id") for row in rows if row.get("trace_id") != trace_id + ] + assert not foreign, ( + f"UC query for current trace {trace_id!r} returned foreign trace rows " + f"{foreign!r}" + ) + return rows + if attempt + 1 < max_attempts: + sleep(interval_seconds) + raise AssertionError( + f"UC trace {trace_id!r} was not ingested into {otel_spans_table!r} " + f"after {max_attempts} attempts" + ) + + # --------------------------------------------------------------------------- # Bundle commands (deploy / run / destroy) # --------------------------------------------------------------------------- @@ -729,6 +1125,7 @@ def bundle_deploy( Handles transient errors with automatic recovery: - Terraform init failures (e.g. GitHub 502): wait and retry + - Fresh UC experiment access propagation: retry the exact app-resource 403 - "already exists" (app): unbind stale state + bind existing app, retry - "does not exist or is deleted": unbind stale reference, retry - "lineage mismatch in state files": a prior run left stale terraform @@ -746,6 +1143,22 @@ def recover(stderr: str, attempt: int, max_attempts: int) -> bool: time.sleep(POLL_INTERVAL) return True + experiment_access_pending = ( + "Invalid Experiment resource experiment" in stderr_flat + and "does not have permission to access Experiment" in stderr_flat + and "403 PERMISSION_DENIED" in stderr_flat + ) + if experiment_access_pending: + if attempt >= EXPERIMENT_ACCESS_MAX_ATTEMPTS: + return False + _log( + f"bundle deploy attempt {attempt}/{EXPERIMENT_ACCESS_MAX_ATTEMPTS} " + f"failed in {template_dir.name} while fresh experiment access " + f"propagates, retrying in {POLL_INTERVAL}s..." + ) + time.sleep(POLL_INTERVAL) + return True + if "already exists" in stderr_flat: _log( f"bundle deploy attempt {attempt}/{max_attempts} failed in " @@ -855,8 +1268,15 @@ def bundle_run_nowait( import subprocess as _subprocess cmd = [ - "databricks", "bundle", "run", resource_key, - "--no-wait", "--target", "dev", "-p", profile, + "databricks", + "bundle", + "run", + resource_key, + "--no-wait", + "--target", + "dev", + "-p", + profile, ] for attempt in range(1, 3): try: @@ -874,9 +1294,9 @@ def bundle_run_nowait( stderr_flat = " ".join(result.stderr.split()) if "Invalid source code path" in stderr_flat and app_name and attempt == 1: _log( - f"bundle run failed: source_code_path missing on workspace " - f"(likely stale state from prior run); re-deploying to " - f"re-upload source, then retrying..." + "bundle run failed: source_code_path missing on workspace " + "(likely stale state from prior run); re-deploying to " + "re-upload source, then retrying..." ) bundle_deploy(template_dir, profile, resource_key, app_name) continue @@ -983,7 +1403,9 @@ def wait_for_app_ready(app_name: str, profile: str) -> tuple[str, str]: break time.sleep(POLL_INTERVAL) else: - raise TimeoutError(f"App {app_name} did not reach RUNNING state within {MAX_POLLS} polls") + raise TimeoutError( + f"App {app_name} did not reach RUNNING state within {MAX_POLLS} polls" + ) # Phase 2: poll /agent/info until the app is actually serving _log(f"App is RUNNING at {app_url}, polling /agent/info...") @@ -1024,7 +1446,13 @@ def capture_app_logs(app_name: str, profile: str) -> str: # Lakebase # --------------------------------------------------------------------------- -_MANAGED_SCHEMAS = ["public", "drizzle", "ai_chatbot", "agent_server", "agent_langgraph_memory"] +_MANAGED_SCHEMAS = [ + "public", + "drizzle", + "ai_chatbot", + "agent_server", + "agent_langgraph_memory", +] def _try_sql(client, sql: str): @@ -1078,7 +1506,9 @@ def grant_lakebase_access( _log(f" Role creation warning: {exc}") # Grant CREATE on database so the SP can create schemas - _try_sql(client, f"GRANT CREATE ON DATABASE databricks_postgres TO {quoted_sp};") + _try_sql( + client, f"GRANT CREATE ON DATABASE databricks_postgres TO {quoted_sp};" + ) # Find managed schemas that exist rows = client.execute( @@ -1127,7 +1557,9 @@ def grant_lakebase_access( # sequences owned by other users. SET ROLE to databricks_superuser # (which HAS been granted on all sequences) to execute grants with # that role's privileges. - _log(" Attempting sequence grants via SET ROLE databricks_superuser...") + _log( + " Attempting sequence grants via SET ROLE databricks_superuser..." + ) try: client.execute("SET ROLE databricks_superuser;") for schema in existing_schemas: @@ -1147,7 +1579,9 @@ def grant_lakebase_access( f"GRANT USAGE, SELECT, UPDATE ON SEQUENCE " f"{seq['schemaname']}.{seq['sequencename']} TO {quoted_sp};", ) - _log(f" Granted on {len(seq_rows)} individual sequence(s) via databricks_superuser") + _log( + f" Granted on {len(seq_rows)} individual sequence(s) via databricks_superuser" + ) client.execute("RESET ROLE;") except Exception as exc: _log(f" SET ROLE databricks_superuser failed: {exc}") @@ -1168,7 +1602,9 @@ def grant_lakebase_access( f"GRANT USAGE, SELECT, UPDATE ON SEQUENCE " f"{seq['schemaname']}.{seq['sequencename']} TO {quoted_sp};", ) - _log(f" Granted on {len(seq_rows)} individual sequence(s) as current user (fallback)") + _log( + f" Granted on {len(seq_rows)} individual sequence(s) as current user (fallback)" + ) # Log sequences owned by other users for debugging current_user = client.execute("SELECT current_user;")[0]["current_user"] @@ -1200,4 +1636,6 @@ def grant_lakebase_access( _log(f"Lakebase access granted to {sp_client_id}.") except Exception as exc: - raise RuntimeError(f"grant_lakebase_access failed for {app_name}: {exc}") from exc + raise RuntimeError( + f"grant_lakebase_access failed for {app_name}: {exc}" + ) from exc diff --git a/.scripts/agent-integration-tests/template_config.py b/.scripts/agent-integration-tests/template_config.py index 3c111d50..bb4627d9 100644 --- a/.scripts/agent-integration-tests/template_config.py +++ b/.scripts/agent-integration-tests/template_config.py @@ -1,6 +1,6 @@ import re import sys -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path # --------------------------------------------------------------------------- @@ -17,6 +17,12 @@ # sets this to a persistent app in the workspace to exercise the # feature end-to-end. DEFAULT_TARGET_APP_NAME = "" +DEFAULT_MLFLOW_UC_CATALOG = "main" +DEFAULT_MLFLOW_UC_SCHEMA = "agent_traces" +DEFAULT_MLFLOW_UC_TABLE_PREFIX = "agents_on_apps" +DEFAULT_MLFLOW_OTEL_SPANS_TABLE = ( + "main.agent_traces.agents_on_apps_otel_spans" +) # --------------------------------------------------------------------------- @@ -177,49 +183,40 @@ def build_templates( serving_endpoint: str = DEFAULT_SERVING_ENDPOINT, target_app_name: str = DEFAULT_TARGET_APP_NAME, ) -> list[TemplateConfig]: - # (name, needs_lakebase, overrides) - configs: list[tuple[str, bool, dict]] = [ - ("agent-langgraph", False, {}), - ("agent-langgraph-advanced", True, {"is_advanced": True}), - ("agent-openai-agents-sdk", False, {}), - ("agent-openai-advanced", True, {"is_advanced": True}), - ( - "agent-openai-agents-sdk-multiagent", - False, - { - "pre_test_edits": _multiagent_edits( - "agent-openai-agents-sdk-multiagent", - genie_space_id, - serving_endpoint, - target_app_name, - ), - "validate_time": False, - }, - ), - ("agent-non-conversational", False, {"is_conversational": False, "has_evaluate": False}), - ("agent-migration-from-model-serving", False, {}), - ] - - # Templates to skip in tests (still listed above for registry validation) - skip_templates = {"agent-migration-from-model-serving"} - - # Validate that all templates from the canonical registry are covered - sys.path.insert(0, str(REPO_ROOT / ".scripts")) - from templates import TEMPLATES as CANONICAL_TEMPLATES - - config_names = {name for name, _, _ in configs} - canonical_names = set(CANONICAL_TEMPLATES.keys()) - missing = canonical_names - config_names - if missing: - raise ValueError( - f"Templates in .scripts/templates.py but not in template_config.py: {missing}. " - "Add them to the configs list or explicitly exclude them." - ) + policy_templates = build_trace_policy_templates() + configs: list[tuple[str, bool, dict]] = [] + for policy in policy_templates: + # The Python E2E runner requires the MLflow AgentServer layout. Other + # discovered surfaces (currently standalone TypeScript) retain their + # own documented local/deployed commands and stay in the policy gate. + if not (policy.path / "agent_server" / "start_server.py").exists(): + continue + if policy.name == "agent-migration-from-model-serving": + # Migration is a reference template without a runnable Apps target. + continue + deployment = (policy.path / "databricks.yml").read_text() + needs_lakebase = bool(re.search(r"\bpostgres:\s*$", deployment, re.MULTILINE)) + overrides: dict = {} + if "advanced" in policy.name: + overrides["is_advanced"] = True + if "multiagent" in policy.name: + overrides.update( + { + "pre_test_edits": _multiagent_edits( + policy.name, + genie_space_id, + serving_endpoint, + target_app_name, + ), + "validate_time": False, + } + ) + if "non-conversational" in policy.name: + overrides.update({"is_conversational": False, "has_evaluate": False}) + configs.append((policy.name, needs_lakebase, overrides)) templates = [] for name, needs_lakebase, overrides in configs: - if name in skip_templates: - continue dev_app_name, app_resource_key = _parse_databricks_yml(name) if needs_lakebase: templates.append( @@ -242,3 +239,31 @@ def build_templates( ) ) return templates + + +def build_trace_policy_templates( + root: Path = REPO_ROOT, + deployed_template_names: set[str] | None = None, +): + """Discover primary agent templates and attach per-template deployed proof. + + Candidate selection is a repository convention plus source behavior, not a + hand-maintained list: any new ``agent-*`` directory detected by the shared + discovery engine enters this gate automatically. + """ + conformance_dir = REPO_ROOT / ".scripts" / "trace-conformance" + sys.path.insert(0, str(conformance_dir)) + from discovery import discover_agentic_templates, is_trace_policy_candidate + + deployed_template_names = deployed_template_names or set() + return [ + replace( + template, + has_deployed_verification=( + template.has_deployed_verification + or template.name in deployed_template_names + ), + ) + for template in discover_agentic_templates(root) + if is_trace_policy_candidate(template) + ] diff --git a/.scripts/agent-integration-tests/test_e2e.py b/.scripts/agent-integration-tests/test_e2e.py index 3ff56aff..2dcbc7fe 100644 --- a/.scripts/agent-integration-tests/test_e2e.py +++ b/.scripts/agent-integration-tests/test_e2e.py @@ -394,6 +394,15 @@ def _run_deploy( for attempt in range(3): try: _query_endpoints(template, app_url, token) + from test_quickstart_e2e import _verify_uc_trace_smoke + + _verify_uc_trace_smoke( + template_dir, + template.dev_app_name, + app_url, + token, + profile, + ) last_exc = None break except Exception as exc: diff --git a/.scripts/agent-integration-tests/test_quickstart_e2e.py b/.scripts/agent-integration-tests/test_quickstart_e2e.py index 16497325..178d01a8 100644 --- a/.scripts/agent-integration-tests/test_quickstart_e2e.py +++ b/.scripts/agent-integration-tests/test_quickstart_e2e.py @@ -36,12 +36,27 @@ uv run pytest test_quickstart_e2e.py -v --scenario existing-app --no-destroy """ -import secrets +import importlib.util +import copy +import hashlib +import json +import math +import os import re +import secrets +import shutil +import subprocess +import sys +import time from pathlib import Path +from types import SimpleNamespace +from urllib.parse import quote import pytest +import requests +from databricks.sdk import WorkspaceClient +import helpers from helpers import ( _log, _run_cmd, @@ -49,15 +64,269 @@ bundle_destroy, databricks_create_app, databricks_delete_app, + execute_trace_row_query, git_copy_template, read_env_value, run_quickstart, set_log_file, wait_for_app_ready, ) +from template_config import ( + DEFAULT_MLFLOW_UC_CATALOG, + DEFAULT_MLFLOW_UC_SCHEMA, + DEFAULT_MLFLOW_UC_TABLE_PREFIX, + REPO_ROOT, +) + +TRACE_CONFORMANCE_DIR = REPO_ROOT / ".scripts" / "trace-conformance" +sys.path.insert(0, str(TRACE_CONFORMANCE_DIR)) +from contract import assert_trace_contract # noqa: E402 +from normalize import normalize_python_mlflow_trace, normalize_uc_rows # noqa: E402 # Fresh app startups can take 5-15 minutes depending on workspace load BUNDLE_RUN_FRESH_TIMEOUT = 900 # 15 minutes +TRACE_PROPAGATION_TIMEOUT = 180 +TRACE_REDACT_KEYS = { + "authorization", + "proxyauthorization", + "cookie", + "setcookie", + "apikey", + "xapikey", + "token", + "accesstoken", + "refreshtoken", + "databrickstoken", + "sdktoken", + "password", + "secret", + "clientsecret", + "credential", + "credentials", +} +REDACTED_TRACE_VALUE = "[REDACTED]" + + +def _capture_metadata(value: str) -> dict[str, object]: + encoded = value.encode("utf-8") + return { + "original_bytes": len(encoded), + "sha256": hashlib.sha256(encoded).hexdigest(), + "truncated": False, + } + + +def _mlflow_trace_fixture(*, cost_available: bool = False): + """Return a fake with the public MLflow 3.14 Trace/Span property shape.""" + trace_id = "trace:/main.agent_traces.agents_on_apps/0123456789abcdef" + request = { + "custom_inputs": { + "Authorization": "Bearer request-secret", + "api_key": "provider-secret", + "request_label": "uc-smoke", + }, + "input": [{"role": "user", "content": "Reply with the word traced."}], + } + redacted_request_json = ( + '{"custom_inputs":{"Authorization":"[REDACTED]",' + '"api_key":"[REDACTED]","request_label":"uc-smoke"},' + '"input":[{"content":"Reply with the word traced.","role":"user"}]}' + ) + response = { + "output": [ + { + "content": [{"text": "traced", "type": "output_text"}], + "role": "assistant", + "type": "message", + } + ], + "status": "completed", + } + response_json = ( + '{"output":[{"content":[{"text":"traced","type":"output_text"}],' + '"role":"assistant","type":"message"}],"status":"completed"}' + ) + root_usage = { + "cache_creation_input_tokens": 2, + "cache_read_input_tokens": 3, + "input_tokens": 7, + "output_tokens": 2, + "total_tokens": 9, + } + root_attributes = { + "mlflow.spanType": "AGENT", + "mlflow.spanInputs": json.loads(redacted_request_json), + "mlflow.spanOutputs": json.loads(response_json), + "mlflow.trace.tokenUsage": dict(root_usage), + "appkit.cost.available": cost_available, + } + for key, value in ( + ("mlflow.spanInputs", redacted_request_json), + ("mlflow.spanOutputs", response_json), + ): + for suffix, metadata_value in _capture_metadata(value).items(): + root_attributes[f"{key}.{suffix}"] = metadata_value + if cost_available: + root_attributes["mlflow.llm.cost"] = 0.0125 + + root = SimpleNamespace( + trace_id=trace_id, + span_id="root-span", + parent_id=None, + name="planner agent", + span_type="AGENT", + inputs=json.loads(redacted_request_json), + outputs=json.loads(response_json), + attributes=root_attributes, + ) + model = SimpleNamespace( + trace_id=trace_id, + span_id="model-span", + parent_id="root-span", + name="databricks dbx-model", + span_type="CHAT_MODEL", + inputs={"messages": [{"role": "user", "content": "Reply"}]}, + outputs={"text": "traced"}, + attributes={ + "mlflow.spanType": "CHAT_MODEL", + "mlflow.chat.provider": "databricks", + "gen_ai.provider.name": "databricks", + "mlflow.chat.model": "dbx-model", + "gen_ai.request.model": "dbx-model", + "mlflow.chat.tokenUsage": dict(root_usage), + "appkit.cache.read_input_tokens": 3, + "appkit.cache.creation_input_tokens": 2, + "appkit.cost.available": cost_available, + **({"mlflow.llm.cost": 0.0125} if cost_available else {}), + }, + ) + trace = SimpleNamespace( + info=SimpleNamespace(trace_id=trace_id), + data=SimpleNamespace(spans=[root, model]), + ) + return trace, request, response + + +def test_trace_contract_accepts_complete_redacted_mlflow_314_shape(): + trace, request, response = _mlflow_trace_fixture() + contract = globals().get("_assert_trace_contract") + assert contract is not None, "trace contract validator is missing" + + contract(trace, request, response) + + +@pytest.mark.parametrize( + "mutation", + [ + "non-agent-root", + "duplicate-root", + "duplicate-provider", + "mixed-trace", + "unredacted-input", + "truncated-input", + "wrong-aggregate-usage", + "collapsed-cache-usage", + "unavailable-cost-present", + "negative-available-cost", + "provider-mismatch", + ], +) +def test_trace_contract_rejects_incomplete_or_duplicate_semantics(mutation): + trace, request, response = _mlflow_trace_fixture( + cost_available=mutation == "negative-available-cost" + ) + root, model = trace.data.spans + if mutation == "non-agent-root": + root.span_type = "CHAIN" + root.attributes["mlflow.spanType"] = "CHAIN" + elif mutation == "duplicate-root": + duplicate = copy.deepcopy(root) + duplicate.span_id = "second-root" + trace.data.spans.append(duplicate) + elif mutation == "duplicate-provider": + duplicate = copy.deepcopy(model) + duplicate.span_id = "second-model" + trace.data.spans.append(duplicate) + elif mutation == "mixed-trace": + model.trace_id = "trace:/main.agent_traces.agents_on_apps/different" + elif mutation == "unredacted-input": + root.inputs = request + root.attributes["mlflow.spanInputs"] = request + elif mutation == "truncated-input": + root.attributes["mlflow.spanInputs.truncated"] = True + elif mutation == "wrong-aggregate-usage": + root.attributes["mlflow.trace.tokenUsage"]["total_tokens"] = 8 + elif mutation == "collapsed-cache-usage": + del root.attributes["mlflow.trace.tokenUsage"][ + "cache_creation_input_tokens" + ] + elif mutation == "unavailable-cost-present": + root.attributes["mlflow.llm.cost"] = 0.0 + elif mutation == "negative-available-cost": + root.attributes["mlflow.llm.cost"] = -1.0 + elif mutation == "provider-mismatch": + model.attributes["gen_ai.provider.name"] = "second-provider" + + contract = globals().get("_assert_trace_contract") + assert contract is not None, "trace contract validator is missing" + with pytest.raises(AssertionError): + contract(trace, request, response) + + +def test_bundle_deploy_retries_while_uc_experiment_access_propagates( + tmp_path, monkeypatch +): + permission_error = subprocess.CompletedProcess( + args=[], + returncode=1, + stdout="", + stderr=( + "Invalid Experiment resource experiment: User does not have permission " + "to access Experiment with ID 12345. (403 PERMISSION_DENIED)" + ), + ) + success = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + results = iter([permission_error, success]) + commands = [] + + def run_cmd(cmd, **_kwargs): + commands.append(cmd) + return next(results) + + monkeypatch.setattr(helpers, "_run_cmd", run_cmd) + monkeypatch.setattr(helpers.time, "sleep", lambda _seconds: None) + + helpers.bundle_deploy(tmp_path, "DEFAULT", "agent_langgraph", "agent-app") + + assert commands == [ + ["databricks", "bundle", "deploy", "--target", "dev", "-p", "DEFAULT"], + ["databricks", "bundle", "deploy", "--target", "dev", "-p", "DEFAULT"], + ] + + +def test_bundle_deploy_bounds_uc_experiment_access_retries(tmp_path, monkeypatch): + permission_error = subprocess.CompletedProcess( + args=[], + returncode=1, + stdout="", + stderr=( + "Invalid Experiment resource experiment: User does not have permission " + "to access Experiment with ID 12345. (403 PERMISSION_DENIED)" + ), + ) + commands = [] + + def run_cmd(cmd, **_kwargs): + commands.append(cmd) + return permission_error + + monkeypatch.setattr(helpers, "_run_cmd", run_cmd) + monkeypatch.setattr(helpers.time, "sleep", lambda _seconds: None) + + with pytest.raises(AssertionError, match="Invalid Experiment resource"): + helpers.bundle_deploy(tmp_path, "DEFAULT", "agent_langgraph", "agent-app") + + assert len(commands) == helpers.EXPERIMENT_ACCESS_MAX_ATTEMPTS def _bundle_run(workdir: Path, app_resource_key: str, profile: str): @@ -105,6 +374,399 @@ def _parse_app_name_from_yml(yml_path: Path) -> str: return match.group(1) +def _load_quickstart_module(workdir: Path): + """Load the synchronized quickstart so E2E exercises its grant/query helpers.""" + module_name = f"quickstart_e2e_{secrets.token_hex(4)}" + script_path = workdir / "scripts" / "quickstart.py" + spec = importlib.util.spec_from_file_location(module_name, script_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def _copy_template_for_quickstart( + template_name: str, tmp_path: Path, git_ref: str | None +) -> Path: + workdir = git_copy_template(template_name, tmp_path, git_ref) + # A locally generated lock lets the live suite remain runnable when PyPI's + # index is temporarily unreachable; the copied workdir is still isolated. + if git_ref is None and (lock := REPO_ROOT / template_name / "uv.lock").exists(): + shutil.copy2(lock, workdir / "uv.lock") + if git_ref is None and (venv := REPO_ROOT / template_name / ".venv").exists(): + subprocess.run( + ["cp", "-cR", str(venv), str(workdir / ".venv")], + check=True, + ) + return workdir + + +def _trace_id_from_response(response: requests.Response) -> str: + if trace_id := response.headers.get("X-MLflow-Trace-Id"): + return trace_id + + def find_trace_id(value): + if isinstance(value, dict): + candidate = value.get("trace_id") + if isinstance(candidate, str) and candidate: + return candidate + for nested in value.values(): + if found := find_trace_id(nested): + return found + elif isinstance(value, list): + for nested in value: + if found := find_trace_id(nested): + return found + return "" + + try: + return find_trace_id(response.json()) + except ValueError: + return "" + + +def _normalize_trace_key(value: str) -> str: + return re.sub(r"[^a-z0-9]", "", value.lower()) + + +def _redact_trace_value(value): + if isinstance(value, dict): + return { + key: ( + REDACTED_TRACE_VALUE + if _normalize_trace_key(str(key)) in TRACE_REDACT_KEYS + else _redact_trace_value(nested) + ) + for key, nested in sorted(value.items()) + } + if isinstance(value, list): + return [_redact_trace_value(nested) for nested in value] + return value + + +def _canonical_trace_json(value) -> str: + return json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + +def _decode_trace_attribute(value, label: str): + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError as error: + raise AssertionError(f"{label} is not valid JSON: {error}") from error + return value + + +def _assert_complete_capture(attributes, key: str, expected) -> object: + actual = _decode_trace_attribute(attributes.get(key), key) + expected_redacted = _redact_trace_value(expected) + assert actual not in (None, "", [], {}), f"{key} is empty" + assert actual == expected_redacted, f"{key} is partial, unredacted, or incorrect" + + encoded = _canonical_trace_json(expected_redacted).encode("utf-8") + assert attributes.get(f"{key}.truncated") is False, f"{key} was truncated" + assert attributes.get(f"{key}.original_bytes") == len(encoded), ( + f"{key} original byte count does not cover the complete redacted value" + ) + assert attributes.get(f"{key}.sha256") == hashlib.sha256(encoded).hexdigest(), ( + f"{key} hash does not cover the complete redacted value" + ) + serialized = _canonical_trace_json(actual) + for secret in ("request-secret", "provider-secret"): + assert secret not in serialized, f"{key} exposed credential-shaped data" + return actual + + +def _usage(attributes, key: str) -> dict[str, int]: + value = _decode_trace_attribute(attributes.get(key), key) + assert isinstance(value, dict), f"{key} must be an object" + required = {"input_tokens", "output_tokens", "total_tokens"} + assert required <= value.keys(), f"{key} is missing base token counts" + assert all( + isinstance(count, int) and not isinstance(count, bool) and count >= 0 + for count in value.values() + ), f"{key} contains an invalid token count" + assert value["total_tokens"] == value["input_tokens"] + value["output_tokens"], ( + f"{key} total_tokens does not equal input_tokens plus output_tokens" + ) + return value + + +def _assert_trace_contract(trace, request, response) -> None: + spans = list(trace.data.spans) + assert spans, "MLflow returned a trace without spans" + trace_id = trace.info.trace_id + assert isinstance(trace_id, str) and trace_id, "MLflow trace has no trace ID" + assert all(span.trace_id == trace_id for span in spans), ( + "Returned spans do not all belong to the retrieved trace" + ) + + roots = [span for span in spans if span.parent_id is None] + assert len(roots) == 1, f"Expected exactly one parentless span, found {len(roots)}" + root = roots[0] + assert root.span_type == "AGENT", "Parentless span is not an AGENT" + assert root.attributes.get("mlflow.spanType") == "AGENT", ( + "Parentless span is missing mlflow.spanType=AGENT" + ) + + span_by_id = {span.span_id: span for span in spans} + assert len(span_by_id) == len(spans), "Trace contains duplicate span IDs" + for span in spans: + if span is root: + continue + assert span.parent_id in span_by_id, f"Span {span.name!r} has an unknown parent" + cursor = span + visited = set() + while cursor is not root: + assert cursor.span_id not in visited, "Trace span tree contains a cycle" + visited.add(cursor.span_id) + cursor = span_by_id[cursor.parent_id] + + captured_inputs = _assert_complete_capture( + root.attributes, "mlflow.spanInputs", request + ) + captured_outputs = _assert_complete_capture( + root.attributes, "mlflow.spanOutputs", response + ) + assert _decode_trace_attribute(root.inputs, "root.inputs") == captured_inputs + assert _decode_trace_attribute(root.outputs, "root.outputs") == captured_outputs + + models = [span for span in spans if span.span_type == "CHAT_MODEL"] + assert models, "Trace contains no CHAT_MODEL descendant" + fingerprints = set() + aggregate: dict[str, int] = {} + all_model_costs_available = True + model_cost = 0.0 + for model in models: + attributes = model.attributes + assert attributes.get("mlflow.spanType") == "CHAT_MODEL" + provider = attributes.get("mlflow.chat.provider") + assert provider == "databricks", f"Unexpected model provider {provider!r}" + assert attributes.get("gen_ai.provider.name") == provider, ( + "MLflow and OpenTelemetry provider attributes disagree" + ) + model_name = attributes.get("mlflow.chat.model") + assert isinstance(model_name, str) and model_name + assert attributes.get("gen_ai.request.model") == model_name + fingerprint = (model.parent_id, model.name, provider, model_name) + assert fingerprint not in fingerprints, "Duplicate model/provider tree detected" + fingerprints.add(fingerprint) + + model_usage = _usage(attributes, "mlflow.chat.tokenUsage") + for key, count in model_usage.items(): + aggregate[key] = aggregate.get(key, 0) + count + if "cache_read_input_tokens" in model_usage: + assert attributes.get("appkit.cache.read_input_tokens") == model_usage[ + "cache_read_input_tokens" + ] + if "cache_creation_input_tokens" in model_usage: + assert attributes.get("appkit.cache.creation_input_tokens") == model_usage[ + "cache_creation_input_tokens" + ] + + available = attributes.get("appkit.cost.available") + assert isinstance(available, bool), "Model cost availability is not boolean" + all_model_costs_available = all_model_costs_available and available + if available: + cost = attributes.get("mlflow.llm.cost") + assert isinstance(cost, (int, float)) and not isinstance(cost, bool) + assert math.isfinite(cost) and cost >= 0, "Model cost is invalid" + model_cost += float(cost) + else: + assert "mlflow.llm.cost" not in attributes, ( + "Unavailable model cost must be omitted" + ) + + root_usage = _usage(root.attributes, "mlflow.trace.tokenUsage") + assert root_usage == aggregate, "Root usage does not include every model descendant" + for cache_key in ("cache_read_input_tokens", "cache_creation_input_tokens"): + if cache_key in aggregate: + assert root_usage.get(cache_key) == aggregate[cache_key], ( + "Root cache token categories were collapsed" + ) + + root_cost_available = root.attributes.get("appkit.cost.available") + assert root_cost_available is all_model_costs_available, ( + "Root cost availability does not cover every model descendant" + ) + if root_cost_available: + root_cost = root.attributes.get("mlflow.llm.cost") + assert isinstance(root_cost, (int, float)) and not isinstance(root_cost, bool) + assert math.isfinite(root_cost) and root_cost >= 0, "Root cost is invalid" + assert math.isclose(float(root_cost), model_cost), ( + "Root cost does not include every priced model descendant" + ) + else: + assert "mlflow.llm.cost" not in root.attributes, ( + "Unavailable root cost must be omitted" + ) + + +def _verify_uc_trace_smoke( + workdir: Path, + app_name: str, + app_url: str, + token: str, + profile: str, +) -> dict[str, str]: + """Invoke the deployed app and prove the trace exists in MLflow and UC.""" + env_file = workdir / ".env" + values = { + name: read_env_value(env_file, name) + for name in ( + "MLFLOW_EXPERIMENT_ID", + "MLFLOW_TRACING_SQL_WAREHOUSE_ID", + "MLFLOW_UC_CATALOG", + "MLFLOW_UC_SCHEMA", + "MLFLOW_UC_TABLE_PREFIX", + "MLFLOW_OTEL_SPANS_TABLE", + ) + } + assert all(values.values()), f"Incomplete MLflow UC config in {env_file}: {values}" + expected_catalog = os.environ.get( + "MLFLOW_UC_CATALOG", DEFAULT_MLFLOW_UC_CATALOG + ) + expected_schema = os.environ.get("MLFLOW_UC_SCHEMA", DEFAULT_MLFLOW_UC_SCHEMA) + expected_prefix = os.environ.get( + "MLFLOW_UC_TABLE_PREFIX", DEFAULT_MLFLOW_UC_TABLE_PREFIX + ) + expected_spans_table = ( + f"{expected_catalog}.{expected_schema}.{expected_prefix}_otel_spans" + ) + assert values["MLFLOW_UC_CATALOG"] == expected_catalog + assert values["MLFLOW_UC_SCHEMA"] == expected_schema + assert values["MLFLOW_UC_TABLE_PREFIX"] == expected_prefix + assert values["MLFLOW_OTEL_SPANS_TABLE"] == expected_spans_table + + quickstart = _load_quickstart_module(workdir) + trace_config = quickstart.MlflowTraceConfig( + experiment_name=f"/Users/{WorkspaceClient(profile=profile).current_user.me().user_name}/agents-on-apps", + experiment_id=values["MLFLOW_EXPERIMENT_ID"], + warehouse_id=values["MLFLOW_TRACING_SQL_WAREHOUSE_ID"], + catalog_name=values["MLFLOW_UC_CATALOG"], + schema_name=values["MLFLOW_UC_SCHEMA"], + table_prefix=values["MLFLOW_UC_TABLE_PREFIX"], + otel_spans_table_name=values["MLFLOW_OTEL_SPANS_TABLE"], + ) + workspace = WorkspaceClient(profile=profile) + quickstart.grant_uc_trace_access_to_app(workspace, app_name, trace_config) + + invocation_request = { + "input": [ + { + "role": "user", + "content": "What time is it? Use the get_current_time tool.", + } + ], + "custom_inputs": { + "Authorization": "Bearer request-secret", + "api_key": "provider-secret", + "request_label": "uc-smoke", + }, + } + response = requests.post( + f"{app_url}/invocations", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "X-MLflow-Return-Trace-Id": "true", + }, + json=invocation_request, + timeout=120, + ) + response.raise_for_status() + try: + invocation_response = response.json() + except ValueError as error: + raise AssertionError("Deployed invocation did not return JSON") from error + trace_id = _trace_id_from_response(response) + assert trace_id, ( + "Deployed invocation returned neither X-MLflow-Trace-Id nor response trace_id: " + f"{response.text[:2000]}" + ) + + import mlflow + from mlflow.entities.trace_location import UnityCatalog + from mlflow.tracing.utils import parse_trace_id_v4 + + tracking_uri = f"databricks://{profile}" + os.environ["MLFLOW_TRACKING_URI"] = tracking_uri + os.environ["MLFLOW_TRACING_SQL_WAREHOUSE_ID"] = trace_config.warehouse_id + mlflow.set_tracking_uri(tracking_uri) + + experiment = mlflow.get_experiment(trace_config.experiment_id) + expected_location = UnityCatalog( + catalog_name=trace_config.catalog_name, + schema_name=trace_config.schema_name, + table_prefix=trace_config.table_prefix, + ) + assert experiment is not None, ( + f"MLflow experiment {trace_config.experiment_id} does not exist" + ) + assert experiment.trace_location == expected_location, ( + "Deployed experiment is not bound to the exact immutable UC location: " + f"experiment={trace_config.experiment_id}, " + f"actual={experiment.trace_location!r}, expected={expected_location!r}" + ) + + _, stored_trace_id = parse_trace_id_v4(trace_id) + stored_trace_id = (stored_trace_id or trace_id).removeprefix("tr-").lower() + trace_candidates = [trace_id.lower(), stored_trace_id, f"tr-{stored_trace_id}"] + + deadline = time.monotonic() + TRACE_PROPAGATION_TIMEOUT + trace = None + trace_rows = [] + last_error = None + while time.monotonic() < deadline: + try: + trace = mlflow.get_trace(trace_id, flush=True) + for candidate in trace_candidates: + trace_rows = execute_trace_row_query( + workspace, + trace_config.warehouse_id, + trace_config.otel_spans_table_name, + candidate, + ) + if trace_rows: + break + if trace is not None and trace_rows: + break + except Exception as error: + last_error = error + time.sleep(10) + + assert trace is not None, f"MLflow could not retrieve trace {trace_id}: {last_error}" + _assert_trace_contract(trace, invocation_request, invocation_response) + normalized_trace = normalize_python_mlflow_trace(workdir.name, trace) + assert_trace_contract(normalized_trace) + assert trace_rows, ( + f"UC spans table {trace_config.otel_spans_table_name} has no rows for " + f"trace {trace_id}; last error: {last_error}" + ) + persisted_manifest = normalize_uc_rows(workdir.name, trace_rows) + assert_trace_contract(persisted_manifest) + + host = workspace.config.host.rstrip("/") + trace_link = ( + f"{host}/ml/experiments/{trace_config.experiment_id}/traces" + f"?selectedTraceId={quote(trace_id, safe='')}" + ) + table_link = ( + f"{host}/explore/data/{trace_config.catalog_name}/" + f"{trace_config.schema_name}/{trace_config.otel_spans_table_name.rsplit('.', 1)[-1]}" + ) + _log(f"[mlflow-uc-smoke] trace_id={trace_id} rows={len(trace_rows)}") + _log(f"[mlflow-uc-smoke] MLflow trace: {trace_link}") + _log(f"[mlflow-uc-smoke] UC spans table: {table_link}") + return {"trace_id": trace_id, "trace_link": trace_link, "table_link": table_link} + + # --------------------------------------------------------------------------- # Parametrize # --------------------------------------------------------------------------- @@ -199,7 +861,7 @@ def _run_fresh_and_idempotent( """ template_name = "agent-langgraph" app_name = _unique_app_name(template_name) - workdir = git_copy_template(template_name, tmp_path, git_ref) + workdir = _copy_template_for_quickstart(template_name, tmp_path, git_ref) _log(f"[fresh-and-idempotent] workdir={workdir}, app_name={app_name}") @@ -244,7 +906,8 @@ def _run_fresh_and_idempotent( try: bundle_deploy(workdir, profile, app_resource_key, app_name) _bundle_run(workdir, app_resource_key, profile) - wait_for_app_ready(app_name, profile) + app_url, token = wait_for_app_ready(app_name, profile) + _verify_uc_trace_smoke(workdir, app_name, app_url, token, profile) _log("[fresh-and-idempotent] App reached RUNNING state and responded to /agent/info") finally: if not no_destroy: @@ -277,7 +940,7 @@ def _run_existing_app( _log(f"[existing-app] Pre-creating app {app_name}") databricks_create_app(app_name, profile) - workdir = git_copy_template(template_name, tmp_path, git_ref) + workdir = _copy_template_for_quickstart(template_name, tmp_path, git_ref) _log(f"[existing-app] workdir={workdir}, app_name={app_name}") try: @@ -306,7 +969,8 @@ def _run_existing_app( _log(f"[existing-app] Deploying (resource key: {app_resource_key})") bundle_deploy(workdir, profile, app_resource_key, app_name) _bundle_run(workdir, app_resource_key, profile) - wait_for_app_ready(app_name, profile) + app_url, token = wait_for_app_ready(app_name, profile) + _verify_uc_trace_smoke(workdir, app_name, app_url, token, profile) _log("[existing-app] App reached RUNNING state") finally: @@ -333,7 +997,7 @@ def _run_lakebase_idempotent( """ template_name = "agent-langgraph-advanced" app_name = _unique_app_name(template_name) - workdir = git_copy_template(template_name, tmp_path, git_ref) + workdir = _copy_template_for_quickstart(template_name, tmp_path, git_ref) _log(f"[lakebase-idempotent] workdir={workdir}, endpoint={lakebase_autoscaling_endpoint}") diff --git a/.scripts/agent-integration-tests/test_trace_conformance.py b/.scripts/agent-integration-tests/test_trace_conformance.py new file mode 100644 index 00000000..874327f8 --- /dev/null +++ b/.scripts/agent-integration-tests/test_trace_conformance.py @@ -0,0 +1,340 @@ +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +INTEGRATION_DIR = Path(__file__).parent +REPO_ROOT = INTEGRATION_DIR.parents[1] +CONFORMANCE_DIR = REPO_ROOT / ".scripts" / "trace-conformance" +sys.path.insert(0, str(CONFORMANCE_DIR)) + +from contract import assert_trace_contract # noqa: E402 +from discovery import ( # noqa: E402 + assert_template_policy, + discover_agentic_templates, + is_trace_policy_candidate, +) +from helpers import ( # noqa: E402 + _run_typescript_trace_probe, + execute_trace_row_query, + poll_trace_rows, + run_local_trace_test, +) +from normalize import load_trace_manifest # noqa: E402 +from template_config import ( # noqa: E402 + TemplateConfig, + build_templates, + build_trace_policy_templates, +) + + +DEPLOYED_TEMPLATE_NAMES = {template.name for template in build_templates()} +TRACE_POLICY_TEMPLATES = build_trace_policy_templates( + deployed_template_names=DEPLOYED_TEMPLATE_NAMES, +) +RUNNABLE_TRACE_POLICY_TEMPLATES = [ + template + for template in TRACE_POLICY_TEMPLATES + if template.local_test_command is not None +] + + +def test_generated_owner_runs_the_full_conformance_file(monkeypatch, tmp_path): + import helpers + + owner = tmp_path / "owner" + owner.mkdir() + template_path = tmp_path / "generated-agent" + template_path.mkdir() + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text( + '{"template":"generated-agent","trace_id":"trace-id","spans":[]}\n' + ) + observed = [] + + def fake_run(command, **kwargs): + observed.append((command, kwargs)) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(helpers, "_run_cmd", fake_run) + template = SimpleNamespace( + name="generated-agent", + path=template_path, + local_test_command=( + "__appkit_generated_owner__", + str(owner), + "generated-agent", + ), + ) + + run_local_trace_test(template, manifest_path) + + command, kwargs = observed[0] + assert command == [ + "npx", + "--yes", + "pnpm@10.21.0", + "exec", + "vitest", + "run", + "packages/appkit/src/plugins/agents/tests/trace-conformance.integration.test.ts", + ] + assert kwargs["cwd"] == owner + + +def test_local_runner_requires_distinct_success_and_injected_failure_manifests( + monkeypatch, tmp_path +): + import helpers + + template_path = tmp_path / "single-manifest-owner" + template_path.mkdir() + success = tmp_path / "success.json" + failure = tmp_path / "failure.json" + success.write_text('{"template":"owner","trace_id":"one","spans":[]}\n') + + monkeypatch.setattr( + helpers, + "_run_cmd", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="", stderr=""), + ) + template = SimpleNamespace( + name="owner", + path=template_path, + local_test_command=("npx", "vitest", "run"), + ) + + with pytest.raises(AssertionError, match="injected-failure manifest"): + run_local_trace_test(template, success, failure) + + +def test_generated_appkit_consumers_use_published_060_runtime(): + for name in ("appkit-agents", "appkit-all-in-one", "rag-chat"): + package = __import__("json").loads( + (REPO_ROOT / name / "package.json").read_text() + ) + assert package["dependencies"]["@databricks/appkit"] == "0.60.0" + assert package["dependencies"]["@databricks/appkit-ui"] == "0.60.0" + + +def test_primary_template_policy_is_derived_from_behavior(): + discovered = [ + template + for template in discover_agentic_templates(REPO_ROOT) + if is_trace_policy_candidate(template) + ] + + assert {template.name for template in TRACE_POLICY_TEMPLATES} == { + template.name for template in discovered + } + assert_template_policy(TRACE_POLICY_TEMPLATES) + + +@pytest.mark.parametrize( + "template", + RUNNABLE_TRACE_POLICY_TEMPLATES, + ids=lambda template: template.name, +) +def test_deterministic_template_turn_writes_a_conformant_manifest(template, tmp_path): + manifest_path = tmp_path / f"{template.name}.success.json" + failure_manifest_path = tmp_path / f"{template.name}.failure.json" + + success, failure = run_local_trace_test( + template, manifest_path, failure_manifest_path + ) + + assert manifest_path.exists() + assert failure_manifest_path.exists() + for manifest in (success, failure): + assert manifest.template == template.name + assert_trace_contract(manifest) + assert all(span.status != "ERROR" for span in success.spans), ( + f"{template.name} success manifest contains an ERROR span" + ) + assert any(span.status == "ERROR" for span in failure.spans), ( + f"{template.name} failure manifest did not execute a real failure" + ) + + +class _StatementExecution: + def __init__(self): + self.calls = [] + + def execute_statement(self, **kwargs): + self.calls.append(kwargs) + return SimpleNamespace( + status=SimpleNamespace(state=SimpleNamespace(value="SUCCEEDED")), + result=SimpleNamespace( + data_array=[ + [ + "trace-id", + "span-id", + None, + "request", + '{"mlflow.spanType":"AGENT"}', + ] + ] + ), + ) + + +def test_deployed_uc_query_is_parameterized_by_table_and_trace_id(): + execution = _StatementExecution() + workspace = SimpleNamespace(statement_execution=execution) + + rows = execute_trace_row_query( + workspace, + "0123456789abcdef", + "main.agent_traces.agents_on_apps_otel_spans", + "trace-id", + ) + + assert rows == [ + { + "trace_id": "trace-id", + "span_id": "span-id", + "parent_span_id": None, + "name": "request", + "attributes": '{"mlflow.spanType":"AGENT"}', + } + ] + assert len(execution.calls) == 1 + call = execution.calls[0] + assert call["statement"] == ( + "SELECT trace_id, span_id, parent_span_id, name, attributes\n" + "FROM IDENTIFIER(:otel_spans_table)\n" + "WHERE trace_id = :trace_id\n" + "ORDER BY start_time_unix_nano" + ) + assert call["warehouse_id"] == "0123456789abcdef" + assert [parameter.as_dict() for parameter in call["parameters"]] == [ + { + "name": "otel_spans_table", + "type": "STRING", + "value": "main.agent_traces.agents_on_apps_otel_spans", + }, + {"name": "trace_id", "type": "STRING", "value": "trace-id"}, + ] + assert call["wait_timeout"] == "50s" + + +class _DelayedUcExecution: + def __init__(self, batches): + self.batches = iter(batches) + + def execute_statement(self, **_kwargs): + return SimpleNamespace( + status=SimpleNamespace(state=SimpleNamespace(value="SUCCEEDED")), + result=SimpleNamespace(data_array=next(self.batches)), + ) + + +def test_uc_ingestion_polling_waits_for_current_trace_rows(): + execution = _DelayedUcExecution( + [ + [], + [], + [["current-trace", "span-id", None, "request", "{}"]], + ] + ) + + rows = poll_trace_rows( + SimpleNamespace(statement_execution=execution), + "0123456789abcdef", + "main.agent_traces.support_otel_spans", + "current-trace", + max_attempts=3, + sleep=lambda _seconds: None, + ) + + assert [row["trace_id"] for row in rows] == ["current-trace"] + + +def test_uc_ingestion_polling_rejects_foreign_rows(): + execution = _DelayedUcExecution( + [[["foreign-trace", "span-id", None, "request", "{}"]]] + ) + + with pytest.raises(AssertionError, match="foreign trace"): + poll_trace_rows( + SimpleNamespace(statement_execution=execution), + "0123456789abcdef", + "main.agent_traces.support_otel_spans", + "current-trace", + max_attempts=1, + sleep=lambda _seconds: None, + ) + + +def test_uc_ingestion_polling_times_out_on_missing_rows(): + execution = _DelayedUcExecution([[], []]) + + with pytest.raises(AssertionError, match="not ingested"): + poll_trace_rows( + SimpleNamespace(statement_execution=execution), + "0123456789abcdef", + "main.agent_traces.support_otel_spans", + "current-trace", + max_attempts=2, + sleep=lambda _seconds: None, + ) + + +def test_deploy_runner_verifies_uc_trace_for_each_parameterized_template( + monkeypatch, + tmp_path, +): + import test_e2e as e2e + import test_quickstart_e2e as quickstart_e2e + + template = TemplateConfig( + name="support-agent", + dev_app_name="dev-support-agent", + app_resource_key="support_agent", + ) + monkeypatch.setattr(e2e, "bundle_deploy", lambda *args: None) + monkeypatch.setattr(e2e, "bundle_run_nowait", lambda *args: None) + monkeypatch.setattr( + e2e, + "wait_for_app_ready", + lambda *args: ("https://support-agent.example", "token"), + ) + monkeypatch.setattr(e2e, "_query_endpoints", lambda *args: None) + observed = [] + monkeypatch.setattr( + quickstart_e2e, + "_verify_uc_trace_smoke", + lambda *args: observed.append(args), + ) + + e2e._run_deploy( + template, + tmp_path, + "dev", + tmp_path / "deploy.log", + no_destroy=True, + ) + + assert observed == [ + ( + tmp_path, + "dev-support-agent", + "https://support-agent.example", + "token", + "dev", + ) + ] + + +def test_independent_typescript_captures_use_sdk_generated_trace_identity(tmp_path): + template_dir = REPO_ROOT / "agent-langchain-ts" + first_path = tmp_path / "first.json" + second_path = tmp_path / "second.json" + + _run_typescript_trace_probe(template_dir, first_path, CONFORMANCE_DIR) + _run_typescript_trace_probe(template_dir, second_path, CONFORMANCE_DIR) + + first = load_trace_manifest(first_path) + second = load_trace_manifest(second_path) + assert first.trace_id != second.trace_id diff --git a/.scripts/source/agent-migration-from-model-serving/preflight.py b/.scripts/source/agent-migration-from-model-serving/preflight.py new file mode 100644 index 00000000..0bb5c21b --- /dev/null +++ b/.scripts/source/agent-migration-from-model-serving/preflight.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +"""Pre-flight check: start the agent locally, send a test request, verify a response. + +Run this before deploying to catch configuration and code errors early. + +Usage: + uv run preflight # Real deployment preflight; validates configured UC + uv run preflight --offline-test # Synthetic local test harness only +""" + +import importlib +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.request +from uuid import uuid4 + +import mlflow + +_IS_WINDOWS = sys.platform == "win32" + +# How long to wait for the server to start (seconds) +SERVER_START_TIMEOUT = 60 +# How long to wait for a response from the agent (seconds) +REQUEST_TIMEOUT = 60 + + +def verify_agent_tracing() -> dict[str, str]: + """Import the scaffold and prove its selected autologger executed.""" + tracing = importlib.import_module("agent_server.tracing") + config = tracing.validate_tracing_environment() + framework = os.environ.get("AGENT_FRAMEWORK", "") + importlib.import_module("agent_server.agent") + if not tracing.selected_autologger_was_called(framework): + raise RuntimeError( + f"Tracing preflight failed: selected autologger did not run for {framework!r}" + ) + return config + + +def verify_deployment_trace_resources( + config: dict[str, str], + *, + mlflow_client=None, + workspace_client=None, +) -> None: + """Prove the configured experiment location and warehouse are available.""" + issues: list[str] = [] + client = mlflow_client or mlflow.MlflowClient() + experiment = None + try: + experiment = client.get_experiment(config["MLFLOW_EXPERIMENT_ID"]) + except Exception as error: + issues.append( + f"experiment {config['MLFLOW_EXPERIMENT_ID']!r} is unavailable: {error}" + ) + if experiment is None: + issues.append(f"experiment {config['MLFLOW_EXPERIMENT_ID']!r} does not exist") + else: + raw_lifecycle = getattr(experiment, "lifecycle_stage", None) + lifecycle = str(getattr(raw_lifecycle, "value", raw_lifecycle) or "") + if lifecycle.lower() != "active": + issues.append( + f"experiment {config['MLFLOW_EXPERIMENT_ID']!r} is unavailable " + f"(lifecycle stage: {lifecycle or 'missing'})" + ) + location = experiment.trace_location + observed = ( + getattr(location, "catalog_name", None), + getattr(location, "schema_name", None), + getattr(location, "table_prefix", None), + getattr(location, "full_otel_spans_table_name", None), + ) + expected = ( + config["MLFLOW_UC_CATALOG"], + config["MLFLOW_UC_SCHEMA"], + config["MLFLOW_UC_TABLE_PREFIX"], + config["MLFLOW_OTEL_SPANS_TABLE"], + ) + if observed != expected: + issues.append( + f"experiment {config['MLFLOW_EXPERIMENT_ID']!r} has wrong UC trace " + f"location {observed!r}; expected {expected!r}" + ) + + if workspace_client is None: + from databricks.sdk import WorkspaceClient + + workspace_client = WorkspaceClient() + warehouse_id = config["MLFLOW_TRACING_SQL_WAREHOUSE_ID"] + try: + warehouse = workspace_client.warehouses.get(warehouse_id) + raw_state = getattr(warehouse, "state", None) + state = str(getattr(raw_state, "value", raw_state) or "") + if state.upper() in {"DELETED", "DELETING"}: + issues.append(f"SQL warehouse {warehouse_id!r} is unavailable (state: {state})") + except Exception as error: + issues.append(f"SQL warehouse {warehouse_id!r} is unavailable: {error}") + + if issues: + raise RuntimeError("Deployment tracing preflight failed: " + "; ".join(issues)) + + +def verify_smoke_trace( + experiment_id: str, + started_ms: int, + request_id: str, + timeout_seconds: float = 15, +) -> str: + """Prove the exact post-smoke request trace can be searched and retrieved.""" + deadline = time.monotonic() + timeout_seconds + last_error: Exception | None = None + while True: + try: + search_kwargs: dict = { + "max_results": 100, + "order_by": ["timestamp_ms DESC"], + "return_type": "list", + "include_spans": False, + "flush": True, + } + if mlflow.get_tracking_uri().startswith("databricks"): + search_kwargs["locations"] = [ + ".".join( + ( + os.environ["MLFLOW_UC_CATALOG"], + os.environ["MLFLOW_UC_SCHEMA"], + os.environ["MLFLOW_UC_TABLE_PREFIX"], + ) + ) + ] + else: + search_kwargs["locations"] = [experiment_id] + traces = mlflow.search_traces(**search_kwargs) + for candidate in traces: + if candidate.info.timestamp_ms < started_ms: + continue + candidate_request_id = candidate.info.trace_metadata.get( + "appkit.request.id" + ) + if candidate_request_id not in {None, request_id}: + continue + trace_id = candidate.info.trace_id + retrieved = mlflow.get_trace(trace_id, flush=True) + if retrieved is None: + continue + metadata_request_id = retrieved.info.trace_metadata.get( + "appkit.request.id" + ) + root_request_ids = { + span.get_attribute("appkit.request.id") + for span in retrieved.data.spans + if span.parent_id is None + } + if metadata_request_id == request_id or request_id in root_request_ids: + return trace_id + except Exception as error: + last_error = error + if time.monotonic() >= deadline: + detail = f": {last_error}" if last_error is not None else "" + raise RuntimeError( + "Tracing preflight failed: exact smoke trace was not retrievable" + + detail + ) + time.sleep(0.5) + + +def run_offline_test() -> None: + """Exercise tracing enforcement and retrieval without a live endpoint.""" + with tempfile.TemporaryDirectory(prefix="migration-preflight-") as directory: + tracking_uri = f"sqlite:///{os.path.join(directory, 'mlflow.db')}" + artifact_dir = Path(directory) / "artifacts" + artifact_dir.mkdir() + mlflow.set_tracking_uri(tracking_uri) + experiment_id = mlflow.MlflowClient().create_experiment( + "migration-offline-preflight", + artifact_location=artifact_dir.as_uri(), + ) + mlflow.set_experiment(experiment_id=experiment_id) + os.environ.update( + { + "MLFLOW_TRACKING_URI": tracking_uri, + "MLFLOW_EXPERIMENT_ID": experiment_id, + "MLFLOW_TRACING_SQL_WAREHOUSE_ID": "0123456789abcdef", + "MLFLOW_UC_CATALOG": "offline_catalog", + "MLFLOW_UC_SCHEMA": "offline_schema", + "MLFLOW_UC_TABLE_PREFIX": "offline_migration", + "MLFLOW_OTEL_SPANS_TABLE": ( + "offline_catalog.offline_schema.offline_migration_otel_spans" + ), + } + ) + + verify_agent_tracing() + framework = os.environ["AGENT_FRAMEWORK"] + print(f"selected autologger ran: {framework}") + + request_id = f"offline-smoke-{uuid4()}" + started_ms = int(time.time() * 1000) + with mlflow.start_span(name="preflight.smoke", span_type="AGENT") as span: + mlflow.update_current_trace( + metadata={"appkit.request.id": request_id} + ) + span.set_inputs({"offline": True, "framework": framework}) + span.set_outputs({"tracing": "enabled"}) + span.set_status("OK") + trace_id = verify_smoke_trace( + experiment_id=experiment_id, + started_ms=started_ms, + request_id=request_id, + ) + print(f"smoke trace retrieved: {trace_id}") + + +def find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +def start_server(port: int) -> subprocess.Popen: + popen_kwargs = {} + if _IS_WINDOWS: + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["preexec_fn"] = os.setsid + + proc = subprocess.Popen( + ["uv", "run", "start-server", "--port", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + **popen_kwargs, + ) + + lines_queue: list[str] = [] + def _reader(): + for line in iter(proc.stderr.readline, ""): + lines_queue.append(line) + + t = threading.Thread(target=_reader, daemon=True) + t.start() + + deadline = time.time() + SERVER_START_TIMEOUT + while time.time() < deadline: + if proc.poll() is not None: + t.join(timeout=2) + stderr = "".join(lines_queue) + print(f" Server exited early (code {proc.returncode})") + if stderr: + for line in stderr.strip().splitlines()[-20:]: + print(f" {line}") + sys.exit(1) + + while lines_queue: + line = lines_queue.pop(0) + if "Uvicorn running on" in line or "Application startup complete" in line: + return proc + + time.sleep(0.5) + + stop_server(proc) + print(f" Server did not start within {SERVER_START_TIMEOUT}s") + sys.exit(1) + + +def stop_server(proc: subprocess.Popen): + if _IS_WINDOWS: + proc.terminate() + else: + import signal + + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except ProcessLookupError: + pass + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +def check_health(base_url: str) -> bool: + try: + req = urllib.request.Request(f"{base_url}/health") + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + return data.get("status") == "healthy" + except Exception as e: + print(f" Health check failed: {e}") + return False + + +def check_invocations(base_url: str, request_id: str, retries: int = 2) -> bool: + payload = json.dumps( + { + "input": [{"role": "user", "content": "Say hello in one word."}], + "custom_inputs": {"request_id": request_id}, + } + ).encode() + + for attempt in range(retries + 1): + try: + req = urllib.request.Request( + f"{base_url}/invocations", + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp: + data = json.loads(resp.read()) + # Check that we got a response with output + if "output" in data and len(data["output"]) > 0: + return True + print(f" Unexpected response shape: {json.dumps(data)[:200]}") + return False + except Exception as e: + if attempt < retries: + print(f" Attempt {attempt + 1} failed ({e}), retrying...") + time.sleep(3) + else: + print(f" Invocations request failed: {e}") + return False + return False + + +def main(): + if "--offline-test" in sys.argv[1:]: + print( + "TEST-ONLY: synthetic local tracing configuration; " + "deployment UC validation is not performed" + ) + run_offline_test() + return + + print("Pre-flight check") + print("=" * 40) + + config = verify_agent_tracing() + verify_deployment_trace_resources(config) + + port = find_free_port() + base_url = f"http://localhost:{port}" + + # Step 1: Start server + print(f"1. Starting server on port {port}...") + proc = start_server(port) + print(" OK") + + try: + # Step 2: Health check + print("2. Health check...") + if not check_health(base_url): + print(" FAILED") + sys.exit(1) + print(" OK") + + # Step 3: Send a test request + print("3. Sending test request to /invocations...") + smoke_request_id = f"preflight-smoke-{uuid4()}" + smoke_started_ms = int(time.time() * 1000) + if not check_invocations(base_url, request_id=smoke_request_id): + print(" FAILED") + sys.exit(1) + print(" OK") + + # Step 4: Prove the smoke request exported a retrievable trace. + print("4. Retrieving smoke trace...") + trace_id = verify_smoke_trace( + experiment_id=config["MLFLOW_EXPERIMENT_ID"], + started_ms=smoke_started_ms, + request_id=smoke_request_id, + ) + print(f" OK ({trace_id})") + + print("=" * 40) + print("Pre-flight check passed!") + + finally: + stop_server(proc) + + +if __name__ == "__main__": + main() diff --git a/.scripts/source/quickstart.py b/.scripts/source/quickstart.py index b39f38e8..8a736264 100644 --- a/.scripts/source/quickstart.py +++ b/.scripts/source/quickstart.py @@ -16,9 +16,9 @@ If the app has an experiment resource, use that ID instead of creating a new one. If the app has a postgres or database resource, build the lakebase config from it (and resolve the endpoint name for local dev .env via the API). - 5. MLflow experiment — if not already set from app resources (step 4), get username, - seed MLFLOW_EXPERIMENT_ID from databricks.yml if not in .env, then create or - reuse an experiment. Update .env and databricks.yml. + 5. MLflow experiment — provision or reuse an experiment permanently bound to a + Unity Catalog trace location through the supported MLflow API. Persist the + experiment, warehouse, catalog, schema, prefix, and spans table atomically. 6. Lakebase setup — skip if already resolved from app resources (step 4). Otherwise: if the template requires Lakebase (has LAKEBASE_* in databricks.yml) or CLI flags are provided, set up via CLI args or interactive selection. @@ -36,6 +36,10 @@ --lakebase-create-new NAME Create a new Lakebase autoscaling project with this name --skip-lakebase Skip Lakebase setup (non-interactive / CI use) --app-name NAME Existing Databricks app name to bind this bundle to + --mlflow-catalog NAME UC catalog for trace tables (default: main) + --mlflow-schema NAME UC schema for trace tables (default: agent_traces) + --mlflow-table-prefix NAME UC trace table prefix (default: agents_on_apps) + --mlflow-warehouse-id ID SQL warehouse for UC trace provisioning -h, --help Show this help message """ @@ -44,16 +48,32 @@ import os import platform import re -import secrets import shutil import subprocess import sys +import tempfile +import time +from dataclasses import dataclass from pathlib import Path +from typing import Any from ruamel.yaml import YAML from ruamel.yaml.scalarstring import DoubleQuotedScalarString +@dataclass(frozen=True) +class MlflowTraceConfig: + """Provisioned MLflow experiment and immutable Unity Catalog trace location.""" + + experiment_name: str + experiment_id: str + warehouse_id: str + catalog_name: str + schema_name: str + table_prefix: str + otel_spans_table_name: str + + def _load_yml(path: Path): """Load a YAML file in round-trip mode (preserves comments and formatting).""" yaml = YAML() @@ -500,51 +520,167 @@ def get_databricks_username(profile_name: str) -> str: sys.exit(1) -def create_mlflow_experiment(profile_name: str, username: str) -> tuple[str, str]: - """Create (or reuse) an MLflow experiment and return (name, id).""" - print_step("Setting up MLflow experiment...") +def _location_name(location: Any) -> str: + """Return a stable display name for an MLflow trace location.""" + if location is None: + return "" + values = ( + getattr(location, "catalog_name", None), + getattr(location, "schema_name", None), + getattr(location, "table_prefix", None), + ) + if all(isinstance(value, str) and value for value in values): + return ".".join(values) + return repr(location) - w = get_workspace_client(profile_name) - if not w: - print_error("Could not connect to Databricks workspace") - print_troubleshooting_api() - sys.exit(1) - # Check if we already have an experiment ID in .env (idempotency) - existing_id = get_env_value("MLFLOW_EXPERIMENT_ID") - if existing_id: +def _warehouse_state(warehouse: Any) -> str: + state = getattr(warehouse, "state", None) + return str(getattr(state, "value", state) or "").upper() + + +def _resolve_mlflow_warehouse(workspace: Any, warehouse_id: str) -> str: + if not warehouse_id: try: - exp = w.experiments.get_experiment(experiment_id=existing_id).experiment - if exp and exp.name: - print_success(f"Reusing existing experiment '{exp.name}' (ID: {existing_id})") - return exp.name, existing_id - except Exception: - pass - print("Existing experiment not found or invalid, creating a new one...") + warehouses = list(workspace.warehouses.list()) + except Exception as error: + raise RuntimeError( + f"Could not list SQL warehouses for MLflow Unity Catalog tracing: {error}" + ) from error + state_priority = {"RUNNING": 0, "STARTING": 1, "STOPPED": 2, "STOPPING": 3} + warehouses.sort(key=lambda item: state_priority.get(_warehouse_state(item), 99)) + warehouse_id = next( + ( + str(getattr(warehouse, "id", "") or "") + for warehouse in warehouses + if getattr(warehouse, "id", None) + and _warehouse_state(warehouse) not in {"DELETED", "DELETING"} + ), + "", + ) + if not warehouse_id: + raise RuntimeError( + "No available SQL warehouse was found for MLflow Unity Catalog tracing. " + "Pass --mlflow-warehouse-id or set MLFLOW_TRACING_SQL_WAREHOUSE_ID." + ) + try: + warehouse = workspace.warehouses.get(warehouse_id) + except Exception as error: + raise RuntimeError( + f"SQL warehouse {warehouse_id!r} is unavailable: {error}" + ) from error + if _warehouse_state(warehouse) in {"DELETED", "DELETING"}: + raise RuntimeError( + f"SQL warehouse {warehouse_id!r} is unavailable " + f"(state: {_warehouse_state(warehouse)})" + ) + return warehouse_id + - experiment_name = f"/Users/{username}/agents-on-apps" +def create_or_reuse_uc_trace_experiment( + profile_name: str, + username: str, + trace_config: Any, +) -> MlflowTraceConfig: + """Create or reuse the fixed agent experiment with an immutable UC location.""" + print_step("Setting up MLflow experiment with Unity Catalog tracing...") + workspace = get_workspace_client(profile_name) + if not workspace: + raise RuntimeError("Could not connect to Databricks workspace") + + warehouse_id = str(getattr(trace_config, "warehouse_id", "") or "") + catalog_name = str(getattr(trace_config, "catalog_name", "") or "") + schema_name = str(getattr(trace_config, "schema_name", "") or "") + table_prefix = str(getattr(trace_config, "table_prefix", "") or "") + for label, value in ( + ("catalog", catalog_name), + ("schema", schema_name), + ("table prefix", table_prefix), + ): + if not value: + raise RuntimeError(f"MLflow Unity Catalog {label} cannot be empty") + warehouse_id = _resolve_mlflow_warehouse(workspace, warehouse_id) + + # Import only after Databricks authentication has been validated so importing + # the quickstart module itself never initializes an MLflow client/provider. + import mlflow + from mlflow.entities.trace_location import UnityCatalog + + tracking_uri = f"databricks://{profile_name}" + os.environ["MLFLOW_TRACKING_URI"] = tracking_uri + os.environ["MLFLOW_TRACING_SQL_WAREHOUSE_ID"] = warehouse_id + mlflow.set_tracking_uri(tracking_uri) + + experiment_name = str( + getattr(trace_config, "experiment_name", "") + or f"/Users/{username}/agents-on-apps" + ) + requested_location = UnityCatalog( + catalog_name=catalog_name, + schema_name=schema_name, + table_prefix=table_prefix, + ) + previous_profile = os.environ.get("DATABRICKS_CONFIG_PROFILE") + os.environ["DATABRICKS_CONFIG_PROFILE"] = profile_name try: - # Try to create with default name - try: - experiment_id = w.experiments.create_experiment(name=experiment_name).experiment_id or "" - print_success(f"Created experiment '{experiment_name}' with ID: {experiment_id}") - return experiment_name, experiment_id - except Exception: - pass + existing_experiment = mlflow.get_experiment_by_name(experiment_name) + existing_location = getattr(existing_experiment, "trace_location", None) + if existing_experiment is not None and existing_location != requested_location: + suggested_name = f"{experiment_name}-uc" + raise RuntimeError( + "MLflow experiment trace locations are immutable. " + f"Experiment: {experiment_name}; " + f"current location: {_location_name(existing_location)}; " + f"requested location: {_location_name(requested_location)}. " + "Select a new experiment name before retrying, for example: " + f"uv run quickstart --mlflow-experiment-name {suggested_name}" + ) + + # This is the only creation path. Do not replace it with the workspace + # create_experiment API: that would silently create an ordinary experiment. + experiment = mlflow.set_experiment( + experiment_name=experiment_name, + trace_location=requested_location, + ) + finally: + if previous_profile is None: + os.environ.pop("DATABRICKS_CONFIG_PROFILE", None) + else: + os.environ["DATABRICKS_CONFIG_PROFILE"] = previous_profile + resolved_location = getattr(experiment, "trace_location", None) + if resolved_location != requested_location: + raise RuntimeError( + "MLflow did not bind the requested immutable Unity Catalog location: " + f"experiment={experiment_name}, " + f"current={_location_name(resolved_location)}, " + f"requested={_location_name(requested_location)}" + ) + experiment_id = str(getattr(experiment, "experiment_id", "") or "") + if not experiment_id: + raise RuntimeError( + f"MLflow returned no experiment ID for {experiment_name!r}" + ) - # Name already exists, try with random suffix - print("Experiment name already exists, creating with random suffix...") - random_suffix = secrets.token_hex(4) - experiment_name = f"/Users/{username}/agents-on-apps-{random_suffix}" - experiment_id = w.experiments.create_experiment(name=experiment_name).experiment_id or "" - print_success(f"Created experiment '{experiment_name}' with ID: {experiment_id}") - return experiment_name, experiment_id + if existing_experiment is None: + print_success( + f"Created UC-bound experiment '{experiment_name}' (ID: {experiment_id})" + ) + else: + print_success( + f"Reusing existing experiment '{experiment_name}' (ID: {experiment_id})" + ) - except Exception as e: - print_error(f"Failed to create MLflow experiment: {e}") - print_troubleshooting_api() - sys.exit(1) + spans_table = f"{catalog_name}.{schema_name}.{table_prefix}_otel_spans" + return MlflowTraceConfig( + experiment_name=experiment_name, + experiment_id=experiment_id, + warehouse_id=warehouse_id, + catalog_name=catalog_name, + schema_name=schema_name, + table_prefix=table_prefix, + otel_spans_table_name=spans_table, + ) def check_lakebase_required() -> bool: @@ -606,32 +742,225 @@ def get_workspace_client(profile_name: str): return None -def get_app_resources(profile_name: str, app_name: str) -> list[dict]: +def _quoted_identifier(value: str) -> str: + return f"`{value.replace('`', '``')}`" + + +def _quoted_string(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _execute_sql(workspace: Any, warehouse_id: str, statement: str) -> Any: + """Execute SQL and wait for a successful terminal status.""" + response = workspace.statement_execution.execute_statement( + statement=statement, + warehouse_id=warehouse_id, + wait_timeout="50s", + ) + for _ in range(120): + status = getattr(response, "status", None) + state = getattr(status, "state", None) + state_value = getattr(state, "value", state) + if state_value == "SUCCEEDED": + return response + if state_value in {"FAILED", "CANCELED", "CLOSED"}: + error = getattr(status, "error", None) + code = getattr(error, "error_code", None) + message = getattr(error, "message", None) + detail = ": ".join(str(value) for value in (code, message) if value) + raise RuntimeError( + f"SQL statement {str(state_value).lower()}: {detail or statement}" + ) + if state_value not in {"PENDING", "RUNNING"}: + raise RuntimeError( + f"SQL statement returned unknown status {state_value!r}: {statement}" + ) + statement_id = getattr(response, "statement_id", None) + if not isinstance(statement_id, str) or not statement_id: + raise RuntimeError( + f"SQL statement is {str(state_value).lower()} without a statement ID" + ) + response = workspace.statement_execution.get_statement(statement_id) + next_state = getattr(getattr(response, "status", None), "state", None) + if getattr(next_state, "value", next_state) in {"PENDING", "RUNNING"}: + time.sleep(1) + raise TimeoutError(f"SQL statement did not finish after 120 polls: {statement}") + + +def _discover_uc_trace_tables( + workspace: Any, config: MlflowTraceConfig +) -> list[tuple[str, str]]: + response = _execute_sql( + workspace, + config.warehouse_id, + " ".join( + [ + "SELECT table_name, table_type", + f"FROM {_quoted_identifier(config.catalog_name)}.information_schema.tables", + f"WHERE table_schema = {_quoted_string(config.schema_name)}", + f"AND table_name LIKE {_quoted_string(f'{config.table_prefix}%')}", + "ORDER BY table_name", + ] + ), + ) + rows = getattr(getattr(response, "result", None), "data_array", None) or [] + return [ + (str(row[0]), str(row[1]).upper()) + for row in rows + if len(row) >= 2 + and row[0] is not None + and row[1] is not None + and str(row[0]).startswith(config.table_prefix) + ] + + +def get_existing_app(workspace: Any, app_name: str) -> Any | None: + """Return an app, deferring only the expected pre-deploy NotFound case.""" + from databricks.sdk.errors import NotFound + + try: + return workspace.apps.get(app_name) + except NotFound: + return None + except Exception as error: + raise RuntimeError( + f"Could not resolve Databricks app {app_name!r}: {error}" + ) from error + + +def grant_uc_trace_access_to_app( + workspace: Any, + app_name: str, + trace_config: MlflowTraceConfig, +) -> None: + """Grant an existing app service principal explicit UC trace-table access.""" + app = get_existing_app(workspace, app_name) + if app is None: + raise RuntimeError( + f"Databricks app {app_name!r} does not exist; deploy it before applying " + "the required MLflow UC trace grants" + ) + principal = getattr(app, "service_principal_client_id", None) + if not isinstance(principal, str) or not principal: + raise RuntimeError( + f"Databricks app {app_name!r} has no service-principal application ID" + ) + + trace_entities = _discover_uc_trace_tables(workspace, trace_config) + table_names = [name for name, _table_type in trace_entities] + expected_spans_table = f"{trace_config.table_prefix}_otel_spans" + if expected_spans_table not in table_names: + raise RuntimeError( + f"Required MLflow trace table {trace_config.otel_spans_table_name} " + f"was not found; discovered: {', '.join(table_names) or ''}" + ) + + catalog = _quoted_identifier(trace_config.catalog_name) + schema = _quoted_identifier(trace_config.schema_name) + grantee = _quoted_identifier(principal) + statements = [ + f"GRANT USE CATALOG ON CATALOG {catalog} TO {grantee}", + f"GRANT USE SCHEMA ON SCHEMA {catalog}.{schema} TO {grantee}", + ] + for table_name, table_type in trace_entities: + entity = f"{catalog}.{schema}.{_quoted_identifier(table_name)}" + if table_type == "VIEW": + statements.append(f"GRANT SELECT ON VIEW {entity} TO {grantee}") + else: + statements.extend( + [ + f"GRANT MODIFY ON TABLE {entity} TO {grantee}", + f"GRANT SELECT ON TABLE {entity} TO {grantee}", + ] + ) + for statement in statements: + _execute_sql(workspace, trace_config.warehouse_id, statement) + + +def write_mlflow_trace_env_atomically( + trace_config: MlflowTraceConfig | Any, + env_file: Path = Path(".env"), +) -> None: + """Persist all six MLflow trace variables with one atomic replacement.""" + values = { + "MLFLOW_EXPERIMENT_ID": str(trace_config.experiment_id), + "MLFLOW_TRACING_SQL_WAREHOUSE_ID": str(trace_config.warehouse_id), + "MLFLOW_UC_CATALOG": str(trace_config.catalog_name), + "MLFLOW_UC_SCHEMA": str(trace_config.schema_name), + "MLFLOW_UC_TABLE_PREFIX": str(trace_config.table_prefix), + "MLFLOW_OTEL_SPANS_TABLE": str(trace_config.otel_spans_table_name), + } + content = env_file.read_text() if env_file.exists() else "" + keys = "|".join(re.escape(key) for key in values) + content = re.sub( + rf"^(?:#\s*)?(?:{keys})=.*(?:\n|$)", + "", + content, + flags=re.MULTILINE, + ) + if content and not content.endswith("\n"): + content += "\n" + content += "".join(f"{key}={value}\n" for key, value in values.items()) + + env_file.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=env_file.parent, + prefix=f".{env_file.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + temporary.write(content) + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path = Path(temporary.name) + os.replace(temporary_path, env_file) + + +def get_app_resources( + profile_name: str, app_name: str, existing_app: Any | None +) -> list[dict]: """Fetch resources from an existing Databricks app. - Returns the resources list from the apps API, or empty list on failure. + A missing pre-deploy app skips this lookup. Once the SDK confirms that the + app exists, command, JSON, and response-shape failures are fatal. """ + if existing_app is None: + return [] + print(f"Fetching resources from app '{app_name}'...") result = run_command( ["databricks", "-p", profile_name, "apps", "get", app_name, "--output", "json"], check=False, ) if result.returncode != 0: - print( - f" Could not fetch app details: " - f"{result.stderr.strip() if result.stderr else 'Unknown error'}" + detail = result.stderr.strip() if result.stderr else "Unknown error" + raise RuntimeError( + f"Could not fetch resources for existing app {app_name!r}: {detail}" ) - return [] try: data = json.loads(result.stdout) - resources = data.get("resources", []) - if resources: - print_success(f"Found {len(resources)} resource(s) in app '{app_name}'") - else: - print(f" App '{app_name}' has no resources configured") - return resources - except (json.JSONDecodeError, KeyError): - return [] + except json.JSONDecodeError as error: + raise RuntimeError( + f"Existing app {app_name!r} returned malformed JSON: {error}" + ) from error + if not isinstance(data, dict): + raise RuntimeError( + f"Existing app {app_name!r} returned an invalid response object" + ) + resources = data.get("resources", []) + if not isinstance(resources, list) or any( + not isinstance(resource, dict) for resource in resources + ): + raise RuntimeError( + f"Existing app {app_name!r} returned invalid resources; expected a list of objects" + ) + if resources: + print_success(f"Found {len(resources)} resource(s) in app '{app_name}'") + else: + print(f" App '{app_name}' has no resources configured") + return resources def create_lakebase_instance(profile_name: str, name: str = None) -> dict: @@ -1294,6 +1623,101 @@ def update_databricks_yml_experiment(experiment_id: str) -> None: print_success("Updated databricks.yml with experiment ID") +def _replace_mlflow_env_entries( + existing: list[Any], + trace_config: MlflowTraceConfig, + *, + value_from_key: str, +) -> list[Any]: + names = { + "MLFLOW_EXPERIMENT_ID", + "MLFLOW_TRACING_SQL_WAREHOUSE_ID", + "MLFLOW_UC_CATALOG", + "MLFLOW_UC_SCHEMA", + "MLFLOW_UC_TABLE_PREFIX", + "MLFLOW_OTEL_SPANS_TABLE", + } + retained = [entry for entry in existing if entry.get("name") not in names] + retained.extend( + [ + { + "name": "MLFLOW_EXPERIMENT_ID", + value_from_key: "experiment", + }, + { + "name": "MLFLOW_TRACING_SQL_WAREHOUSE_ID", + value_from_key: "mlflow-tracing-warehouse", + }, + {"name": "MLFLOW_UC_CATALOG", "value": trace_config.catalog_name}, + {"name": "MLFLOW_UC_SCHEMA", "value": trace_config.schema_name}, + {"name": "MLFLOW_UC_TABLE_PREFIX", "value": trace_config.table_prefix}, + { + "name": "MLFLOW_OTEL_SPANS_TABLE", + "value": trace_config.otel_spans_table_name, + }, + ] + ) + return retained + + +def update_mlflow_trace_runtime_config(trace_config: MlflowTraceConfig) -> None: + """Persist trace config to the bundle and direct app runtime configuration.""" + bundle_path = Path("databricks.yml") + if bundle_path.exists(): + yaml, data = _load_yml(bundle_path) + apps = data.get("resources", {}).get("apps", {}) + for app in apps.values(): + config = app.setdefault("config", {}) + config["env"] = _replace_mlflow_env_entries( + list(config.get("env", [])), + trace_config, + value_from_key="value_from", + ) + resources = list(app.get("resources", [])) + experiment_found = False + warehouse_found = False + for resource in resources: + if "experiment" in resource: + resource["experiment"]["experiment_id"] = ( + DoubleQuotedScalarString(trace_config.experiment_id) + ) + experiment_found = True + if resource.get("name") == "mlflow-tracing-warehouse": + resource["sql_warehouse"] = { + "id": trace_config.warehouse_id, + "permission": "CAN_USE", + } + warehouse_found = True + if not experiment_found: + raise RuntimeError( + "databricks.yml app resource is missing its MLflow experiment binding" + ) + if not warehouse_found: + resources.append( + { + "name": "mlflow-tracing-warehouse", + "sql_warehouse": { + "id": trace_config.warehouse_id, + "permission": "CAN_USE", + }, + } + ) + app["resources"] = resources + _save_yml(yaml, data, bundle_path) + print_success("Updated databricks.yml with MLflow UC tracing config") + + app_path = Path("app.yaml") + if app_path.exists(): + yaml, data = _load_yml(app_path) + data["env"] = _replace_mlflow_env_entries( + list(data.get("env", [])), + trace_config, + value_from_key="valueFrom", + ) + _save_yml(yaml, data, app_path) + print_success("Updated app.yaml with MLflow UC tracing config") + + def update_databricks_yml_app_name(app_name: str, budget_policy_id: str | None = None) -> str: """Update the app name field in databricks.yml. @@ -1328,7 +1752,7 @@ def update_databricks_yml_app_name(app_name: str, budget_policy_id: str | None = return app_key -def main(): +def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Quickstart setup for Databricks agent development", formatter_class=argparse.RawDescriptionHelpFormatter, @@ -1373,8 +1797,45 @@ def main(): help="Existing Databricks app name to bind this bundle to", metavar="NAME", ) + parser.add_argument( + "--mlflow-catalog", + default=os.environ.get("MLFLOW_UC_CATALOG", "main"), + help="Unity Catalog catalog for MLflow trace tables", + metavar="NAME", + ) + parser.add_argument( + "--mlflow-schema", + default=os.environ.get("MLFLOW_UC_SCHEMA", "agent_traces"), + help="Unity Catalog schema for MLflow trace tables", + metavar="NAME", + ) + parser.add_argument( + "--mlflow-table-prefix", + default=os.environ.get("MLFLOW_UC_TABLE_PREFIX", "agents_on_apps"), + help="Table prefix for MLflow Unity Catalog trace tables", + metavar="NAME", + ) + parser.add_argument( + "--mlflow-warehouse-id", + default=os.environ.get("MLFLOW_TRACING_SQL_WAREHOUSE_ID"), + help="SQL warehouse used to provision and query MLflow UC trace tables", + metavar="ID", + ) + parser.add_argument( + "--mlflow-experiment-name", + default=os.environ.get("MLFLOW_EXPERIMENT_NAME"), + help="Absolute MLflow experiment name (defaults to /Users//agents-on-apps)", + metavar="PATH", + ) + return parser + - args = parser.parse_args() +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + return _build_parser().parse_args(argv) + + +def main(): + args = _parse_args() try: print_header("Agent on Apps - Quickstart Setup") @@ -1417,19 +1878,17 @@ def main(): bundle_key = "" lakebase_config = None - app_experiment_id = None + existing_app = None if app_name: bundle_key = update_databricks_yml_app_name(app_name) + workspace = get_workspace_client(profile_name) + if not workspace: + raise RuntimeError("Could not connect to Databricks workspace") + existing_app = get_existing_app(workspace, app_name) # Fetch resources from the existing app and use them in databricks.yml - app_resources = get_app_resources(profile_name, app_name) + app_resources = get_app_resources(profile_name, app_name, existing_app) for resource in app_resources: - if "experiment" in resource: - app_exp_id = resource["experiment"].get("experiment_id", "") - if app_exp_id: - app_experiment_id = app_exp_id - print_success(f"Found experiment ID from app: {app_exp_id}") - if "postgres" in resource: pg = resource["postgres"] lakebase_config = {"type": "autoscaling"} @@ -1481,36 +1940,37 @@ def main(): update_env_file("PGUSER", username) print_success(f"PGUSER set to '{username}'") - # Use experiment ID from app if available, otherwise create/reuse one - if app_experiment_id: - experiment_id = app_experiment_id - experiment_name = experiment_id - # Try to resolve experiment name for display - w = get_workspace_client(profile_name) - if w: - try: - exp = w.experiments.get_experiment(experiment_id=experiment_id).experiment - if exp and exp.name: - experiment_name = exp.name - except Exception: - pass - update_env_file("MLFLOW_EXPERIMENT_ID", experiment_id) - update_databricks_yml_experiment(experiment_id) - print_success(f"Using experiment ID from app: {experiment_id}") - else: - # Seed MLFLOW_EXPERIMENT_ID from databricks.yml if not already in .env. - # This handles the case where the user created the app via the Databricks UI, - # downloaded the template (which has the experiment_id in databricks.yml already), - # and is now running quickstart for the first time locally. - if not get_env_value("MLFLOW_EXPERIMENT_ID"): - yml_experiment_id = get_databricks_yml_experiment_id() - if yml_experiment_id: - update_env_file("MLFLOW_EXPERIMENT_ID", yml_experiment_id) - - experiment_name, experiment_id = create_mlflow_experiment(profile_name, username) - update_env_file("MLFLOW_EXPERIMENT_ID", experiment_id) - print_success("Updated .env with experiment ID") - update_databricks_yml_experiment(experiment_id) + requested_trace_config = argparse.Namespace( + experiment_name=args.mlflow_experiment_name, + warehouse_id=args.mlflow_warehouse_id, + catalog_name=args.mlflow_catalog, + schema_name=args.mlflow_schema, + table_prefix=args.mlflow_table_prefix, + ) + trace_config = create_or_reuse_uc_trace_experiment( + profile_name, + username, + requested_trace_config, + ) + write_mlflow_trace_env_atomically(trace_config) + print_success("Updated .env with complete MLflow UC tracing config") + update_mlflow_trace_runtime_config(trace_config) + experiment_name = trace_config.experiment_name + experiment_id = trace_config.experiment_id + + if app_name and existing_app is not None: + workspace = get_workspace_client(profile_name) + if not workspace: + raise RuntimeError("Could not connect to Databricks workspace") + grant_uc_trace_access_to_app(workspace, app_name, trace_config) + print_success( + f"Granted app '{app_name}' explicit access to MLflow UC trace tables" + ) + elif app_name: + print( + f"App '{app_name}' does not exist yet. After the first deploy, rerun " + "quickstart with the same --app-name to apply required MLflow UC grants." + ) # Step 6: Lakebase setup # lakebase_config may already be set from app resources above @@ -1572,7 +2032,9 @@ def main(): ✓ Configuration files created (.env) ✓ MLflow experiment set up for tracing and evaluation: {experiment_name} -✓ Experiment ID: {experiment_id}""" +✓ Experiment ID: {experiment_id} +✓ MLflow UC spans table: {trace_config.otel_spans_table_name} +✓ MLflow tracing SQL warehouse: {trace_config.warehouse_id}""" if host and experiment_id: summary += f"\n {host}/ml/experiments/{experiment_id}" diff --git a/.scripts/source/test_quickstart.py b/.scripts/source/test_quickstart.py index d0375aa6..2056ae56 100644 --- a/.scripts/source/test_quickstart.py +++ b/.scripts/source/test_quickstart.py @@ -12,16 +12,20 @@ import json import os +import subprocess from pathlib import Path -from unittest.mock import MagicMock, patch +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, patch import pytest +from mlflow.entities.trace_location import UnityCatalog + +import quickstart from quickstart import ( _replace_lakebase_env_vars, _replace_lakebase_resource, create_lakebase_instance, - create_mlflow_experiment, get_databricks_yml_experiment_id, get_existing_lakebase_config, setup_env_file, @@ -725,77 +729,451 @@ def test_autoscaling_happy_path(self, tmp_path): ) -def _mock_workspace_client(get_experiment_result=None, get_experiment_raises=False, - create_experiment_id="99999"): - """Build a mock WorkspaceClient for experiment tests.""" - mock_w = MagicMock() - if get_experiment_raises: - mock_w.experiments.get_experiment.side_effect = Exception("not found") - else: - mock_exp = MagicMock() - mock_exp.experiment = get_experiment_result - mock_w.experiments.get_experiment.return_value = mock_exp - mock_create = MagicMock() - mock_create.experiment_id = create_experiment_id - mock_w.experiments.create_experiment.return_value = mock_create - return mock_w - - -class TestExperimentIdempotency: - """Tests for experiment reuse logic in create_mlflow_experiment.""" - - def test_reuses_existing_id_in_env(self, tmp_path): - """When .env has a valid experiment ID, returns it without creating a new one.""" - (tmp_path / ".env").write_text("MLFLOW_EXPERIMENT_ID=12345\n") - existing_exp = MagicMock(name_="/Users/test/agents-on-apps", experiment_id="12345") - existing_exp.name = "/Users/test/agents-on-apps" - mock_w = _mock_workspace_client(get_experiment_result=existing_exp) - with patch("quickstart.get_workspace_client", return_value=mock_w): - name, exp_id = create_mlflow_experiment("DEFAULT", "test@example.com") +def _uc_request(warehouse_id="0123456789abcdef"): + return SimpleNamespace( + warehouse_id=warehouse_id, + catalog_name="main", + schema_name="agent_traces", + table_prefix="agents_on_apps", + ) - assert exp_id == "12345" - assert name == "/Users/test/agents-on-apps" - mock_w.experiments.get_experiment.assert_called_once_with(experiment_id="12345") - mock_w.experiments.create_experiment.assert_not_called() - def test_creates_new_if_id_missing(self, tmp_path): - """When .env has no MLFLOW_EXPERIMENT_ID, creates a new experiment.""" - (tmp_path / ".env").write_text("DATABRICKS_CONFIG_PROFILE=DEFAULT\n") - mock_w = _mock_workspace_client(create_experiment_id="99999") - with patch("quickstart.get_workspace_client", return_value=mock_w): - name, exp_id = create_mlflow_experiment("DEFAULT", "test@example.com") +def _uc_location(catalog="main", schema="agent_traces", prefix="agents_on_apps"): + return UnityCatalog(catalog_name=catalog, schema_name=schema, table_prefix=prefix) - assert exp_id == "99999" - mock_w.experiments.get_experiment.assert_not_called() - mock_w.experiments.create_experiment.assert_called_once() - def test_creates_new_if_experiment_deleted(self, tmp_path): - """When .env has ID but get_experiment fails, creates a new experiment.""" - (tmp_path / ".env").write_text("MLFLOW_EXPERIMENT_ID=deleted-id\n") - mock_w = _mock_workspace_client(get_experiment_raises=True, create_experiment_id="new-id") - with patch("quickstart.get_workspace_client", return_value=mock_w): - name, exp_id = create_mlflow_experiment("DEFAULT", "test@example.com") +def _experiment(location=None, name="/Users/user@example.com/agents-on-apps"): + return SimpleNamespace( + experiment_id="12345", + name=name, + trace_location=location, + ) + + +def _workspace_with_warehouse(warehouse_id="0123456789abcdef"): + workspace = MagicMock() + workspace.warehouses.get.return_value = SimpleNamespace( + id=warehouse_id, + state=SimpleNamespace(value="RUNNING"), + ) + return workspace + + +class TestUcTraceExperimentSetup: + """Supported UC setup is mandatory; an ordinary experiment is never a fallback.""" + + def test_fresh_setup_uses_supported_uc_location_and_returns_full_config(self, monkeypatch): + workspace = _workspace_with_warehouse() + mock_set_experiment = Mock(return_value=_experiment(_uc_location())) + mock_get_by_name = Mock(return_value=None) + mock_create_ordinary = workspace.experiments.create_experiment + + monkeypatch.setattr(quickstart, "get_workspace_client", lambda _profile: workspace) + monkeypatch.setattr("mlflow.set_experiment", mock_set_experiment) + monkeypatch.setattr("mlflow.get_experiment_by_name", mock_get_by_name) + + trace_config = quickstart.create_or_reuse_uc_trace_experiment( + "DEFAULT", "user@example.com", _uc_request() + ) + + assert trace_config == quickstart.MlflowTraceConfig( + experiment_name="/Users/user@example.com/agents-on-apps", + experiment_id="12345", + warehouse_id="0123456789abcdef", + catalog_name="main", + schema_name="agent_traces", + table_prefix="agents_on_apps", + otel_spans_table_name="main.agent_traces.agents_on_apps_otel_spans", + ) + mock_set_experiment.assert_called_once_with( + experiment_name="/Users/user@example.com/agents-on-apps", + trace_location=UnityCatalog( + catalog_name="main", + schema_name="agent_traces", + table_prefix="agents_on_apps", + ), + ) + mock_create_ordinary.assert_not_called() + + def test_exact_location_reuse_keeps_the_same_experiment(self, monkeypatch): + workspace = _workspace_with_warehouse() + existing = _experiment(_uc_location()) + mock_set_experiment = Mock(return_value=existing) + monkeypatch.setattr(quickstart, "get_workspace_client", lambda _profile: workspace) + monkeypatch.setattr("mlflow.get_experiment_by_name", Mock(return_value=existing)) + monkeypatch.setattr("mlflow.set_experiment", mock_set_experiment) + + result = quickstart.create_or_reuse_uc_trace_experiment( + "DEFAULT", "user@example.com", _uc_request() + ) + + assert result.experiment_id == "12345" + assert result.otel_spans_table_name == "main.agent_traces.agents_on_apps_otel_spans" + mock_set_experiment.assert_called_once() + workspace.experiments.create_experiment.assert_not_called() + + def test_conflicting_immutable_location_is_fatal_and_names_both_locations( + self, monkeypatch + ): + workspace = _workspace_with_warehouse() + existing = _experiment(_uc_location("legacy", "traces", "old_prefix")) + mock_set_experiment = Mock() + monkeypatch.setattr(quickstart, "get_workspace_client", lambda _profile: workspace) + monkeypatch.setattr("mlflow.get_experiment_by_name", Mock(return_value=existing)) + monkeypatch.setattr("mlflow.set_experiment", mock_set_experiment) + + with pytest.raises(RuntimeError) as error: + quickstart.create_or_reuse_uc_trace_experiment( + "DEFAULT", "user@example.com", _uc_request() + ) + + message = str(error.value) + assert "/Users/user@example.com/agents-on-apps" in message + assert "legacy.traces.old_prefix" in message + assert "main.agent_traces.agents_on_apps" in message + assert "--mlflow-experiment-name" in message + mock_set_experiment.assert_not_called() + workspace.experiments.create_experiment.assert_not_called() + + def test_missing_uc_preview_is_fatal_without_ordinary_experiment_fallback( + self, monkeypatch + ): + workspace = _workspace_with_warehouse() + monkeypatch.setattr(quickstart, "get_workspace_client", lambda _profile: workspace) + monkeypatch.setattr("mlflow.get_experiment_by_name", Mock(return_value=None)) + monkeypatch.setattr( + "mlflow.set_experiment", + Mock(side_effect=RuntimeError("Unity Catalog tracing preview is not enabled")), + ) + + with pytest.raises(RuntimeError, match="preview is not enabled"): + quickstart.create_or_reuse_uc_trace_experiment( + "DEFAULT", "user@example.com", _uc_request() + ) + + workspace.experiments.create_experiment.assert_not_called() + + def test_unavailable_requested_warehouse_is_fatal_before_mlflow_setup(self, monkeypatch): + workspace = MagicMock() + workspace.warehouses.get.side_effect = RuntimeError("warehouse not found") + mock_set_experiment = Mock() + monkeypatch.setattr(quickstart, "get_workspace_client", lambda _profile: workspace) + monkeypatch.setattr("mlflow.set_experiment", mock_set_experiment) + + with pytest.raises(RuntimeError, match="0123456789abcdef"): + quickstart.create_or_reuse_uc_trace_experiment( + "DEFAULT", "user@example.com", _uc_request() + ) + + mock_set_experiment.assert_not_called() + workspace.experiments.create_experiment.assert_not_called() + + def test_selects_an_available_warehouse_when_no_noninteractive_default_exists( + self, monkeypatch + ): + workspace = MagicMock() + workspace.warehouses.list.return_value = [ + SimpleNamespace(id="deleted", state=SimpleNamespace(value="DELETED")), + SimpleNamespace(id="running-warehouse", state=SimpleNamespace(value="RUNNING")), + ] + experiment = _experiment(_uc_location()) + monkeypatch.setattr(quickstart, "get_workspace_client", lambda _profile: workspace) + monkeypatch.setattr("mlflow.get_experiment_by_name", Mock(return_value=None)) + monkeypatch.setattr("mlflow.set_experiment", Mock(return_value=experiment)) + + result = quickstart.create_or_reuse_uc_trace_experiment( + "DEFAULT", "user@example.com", _uc_request(warehouse_id=None) + ) + + assert result.warehouse_id == "running-warehouse" + + def test_scopes_selected_profile_for_mlflow_internal_warehouse_auth( + self, monkeypatch + ): + workspace = _workspace_with_warehouse() + monkeypatch.setenv("DATABRICKS_CONFIG_PROFILE", "outer-profile") + monkeypatch.setattr(quickstart, "get_workspace_client", lambda _profile: workspace) + monkeypatch.setattr("mlflow.get_experiment_by_name", Mock(return_value=None)) + + def set_experiment(**_kwargs): + assert os.environ["DATABRICKS_CONFIG_PROFILE"] == "selected-profile" + return _experiment(_uc_location()) + + monkeypatch.setattr("mlflow.set_experiment", Mock(side_effect=set_experiment)) + + quickstart.create_or_reuse_uc_trace_experiment( + "selected-profile", "user@example.com", _uc_request() + ) + + assert os.environ["DATABRICKS_CONFIG_PROFILE"] == "outer-profile" + + def test_explicit_experiment_name_selects_a_new_immutable_binding(self, monkeypatch): + workspace = _workspace_with_warehouse() + request = _uc_request() + request.experiment_name = "/Users/user@example.com/agents-on-apps-unique" + experiment = _experiment(_uc_location(), name=request.experiment_name) + mock_set_experiment = Mock(return_value=experiment) + mock_get_by_name = Mock(return_value=None) + monkeypatch.setattr(quickstart, "get_workspace_client", lambda _profile: workspace) + monkeypatch.setattr("mlflow.get_experiment_by_name", mock_get_by_name) + monkeypatch.setattr("mlflow.set_experiment", mock_set_experiment) + + result = quickstart.create_or_reuse_uc_trace_experiment( + "DEFAULT", "user@example.com", request + ) + + assert result.experiment_name == request.experiment_name + mock_get_by_name.assert_called_once_with(request.experiment_name) + assert mock_set_experiment.call_args.kwargs["experiment_name"] == request.experiment_name + + +class TestUcTraceAppPermissions: + def test_missing_app_is_deferred_until_after_first_deploy(self): + from databricks.sdk.errors import NotFound + + workspace = MagicMock() + workspace.apps.get.side_effect = NotFound("app does not exist") + + assert quickstart.get_existing_app(workspace, "future-app") is None + + def test_app_lookup_setup_failure_is_fatal(self): + workspace = MagicMock() + workspace.apps.get.side_effect = RuntimeError("apps API unavailable") - assert exp_id == "new-id" - mock_w.experiments.create_experiment.assert_called_once() + with pytest.raises(RuntimeError, match="apps API unavailable"): + quickstart.get_existing_app(workspace, "future-app") - def test_still_updates_yml_on_reuse(self, tmp_path): - """Even when reusing an experiment, databricks.yml gets the experiment_id set.""" + def test_missing_app_skips_cli_resource_lookup(self, monkeypatch): + mock_run = Mock() + monkeypatch.setattr(quickstart, "run_command", mock_run) + + assert quickstart.get_app_resources("DEFAULT", "future-app", None) == [] + mock_run.assert_not_called() + + @pytest.mark.parametrize( + ("result", "message"), + [ + ( + subprocess.CompletedProcess( + args=[], returncode=1, stdout="", stderr="permission denied" + ), + "permission denied", + ), + ( + subprocess.CompletedProcess( + args=[], returncode=0, stdout="not-json", stderr="" + ), + "malformed JSON", + ), + ( + subprocess.CompletedProcess( + args=[], + returncode=0, + stdout='{"resources": {"experiment": {}}}', + stderr="", + ), + "invalid resources", + ), + ], + ids=["command-failure", "malformed-json", "invalid-resource-shape"], + ) + def test_existing_app_resource_lookup_failures_are_fatal( + self, monkeypatch, result, message + ): + monkeypatch.setattr(quickstart, "run_command", Mock(return_value=result)) + + with pytest.raises(RuntimeError, match=message): + quickstart.get_app_resources( + "DEFAULT", "existing-app", SimpleNamespace(name="existing-app") + ) + + def test_grants_modify_to_tables_and_select_to_every_trace_entity(self): + workspace = MagicMock() + workspace.apps.get.return_value = SimpleNamespace( + service_principal_client_id="app-client-id" + ) + responses = [ + SimpleNamespace( + status=SimpleNamespace(state=SimpleNamespace(value="SUCCEEDED")), + result=SimpleNamespace( + data_array=[ + ["agents_on_apps_otel_annotations", "MANAGED"], + ["agents_on_apps_otel_logs", "MANAGED"], + ["agents_on_apps_otel_metrics", "MANAGED"], + ["agents_on_apps_otel_spans", "MANAGED"], + ["agents_on_apps_trace_metadata", "VIEW"], + ["agents_on_apps_trace_unified", "VIEW"], + ] + ), + ) + ] + [ + SimpleNamespace( + status=SimpleNamespace(state=SimpleNamespace(value="SUCCEEDED")), + result=SimpleNamespace(data_array=[]), + ) + for _ in range(12) + ] + workspace.statement_execution.execute_statement.side_effect = responses + + quickstart.grant_uc_trace_access_to_app( + workspace, + "existing-agent-app", + quickstart.MlflowTraceConfig( + "/Users/user@example.com/agents-on-apps", + "12345", + "0123456789abcdef", + "main", + "agent_traces", + "agents_on_apps", + "main.agent_traces.agents_on_apps_otel_spans", + ), + ) + + workspace.apps.get.assert_called_once_with("existing-agent-app") + statements = [ + call.kwargs["statement"] + for call in workspace.statement_execution.execute_statement.call_args_list + ] + assert statements[1:] == [ + "GRANT USE CATALOG ON CATALOG `main` TO `app-client-id`", + "GRANT USE SCHEMA ON SCHEMA `main`.`agent_traces` TO `app-client-id`", + "GRANT MODIFY ON TABLE `main`.`agent_traces`.`agents_on_apps_otel_annotations` TO `app-client-id`", + "GRANT SELECT ON TABLE `main`.`agent_traces`.`agents_on_apps_otel_annotations` TO `app-client-id`", + "GRANT MODIFY ON TABLE `main`.`agent_traces`.`agents_on_apps_otel_logs` TO `app-client-id`", + "GRANT SELECT ON TABLE `main`.`agent_traces`.`agents_on_apps_otel_logs` TO `app-client-id`", + "GRANT MODIFY ON TABLE `main`.`agent_traces`.`agents_on_apps_otel_metrics` TO `app-client-id`", + "GRANT SELECT ON TABLE `main`.`agent_traces`.`agents_on_apps_otel_metrics` TO `app-client-id`", + "GRANT MODIFY ON TABLE `main`.`agent_traces`.`agents_on_apps_otel_spans` TO `app-client-id`", + "GRANT SELECT ON TABLE `main`.`agent_traces`.`agents_on_apps_otel_spans` TO `app-client-id`", + "GRANT SELECT ON VIEW `main`.`agent_traces`.`agents_on_apps_trace_metadata` TO `app-client-id`", + "GRANT SELECT ON VIEW `main`.`agent_traces`.`agents_on_apps_trace_unified` TO `app-client-id`", + ] + assert "ALL PRIVILEGES" not in "\n".join(statements) + + +class TestAtomicMlflowTraceEnv: + def test_writes_all_six_trace_values_in_one_atomic_replace(self, tmp_path, monkeypatch): + env_file = tmp_path / ".env" + env_file.write_text( + "DATABRICKS_CONFIG_PROFILE=DEFAULT\n" + "MLFLOW_EXPERIMENT_ID=stale\n" + "MLFLOW_EXPERIMENT_ID=duplicate\n" + ) + trace_config = SimpleNamespace( + experiment_id="12345", + warehouse_id="0123456789abcdef", + catalog_name="main", + schema_name="agent_traces", + table_prefix="agents_on_apps", + otel_spans_table_name="main.agent_traces.agents_on_apps_otel_spans", + ) + real_replace = os.replace + replacements = [] + + def recording_replace(source, destination): + replacements.append((Path(source), Path(destination))) + real_replace(source, destination) + + monkeypatch.setattr(os, "replace", recording_replace) + + quickstart.write_mlflow_trace_env_atomically(trace_config, env_file) + + assert len(replacements) == 1 + assert replacements[0][1] == env_file + active = { + line.split("=", 1)[0]: line.split("=", 1)[1] + for line in env_file.read_text().splitlines() + if line and not line.startswith("#") and "=" in line + } + assert active == { + "DATABRICKS_CONFIG_PROFILE": "DEFAULT", + "MLFLOW_EXPERIMENT_ID": "12345", + "MLFLOW_TRACING_SQL_WAREHOUSE_ID": "0123456789abcdef", + "MLFLOW_UC_CATALOG": "main", + "MLFLOW_UC_SCHEMA": "agent_traces", + "MLFLOW_UC_TABLE_PREFIX": "agents_on_apps", + "MLFLOW_OTEL_SPANS_TABLE": "main.agent_traces.agents_on_apps_otel_spans", + } + + +class TestMlflowTraceCliAndRuntimeConfig: + def test_cli_defaults_to_environment_backed_uc_coordinates(self, monkeypatch): + monkeypatch.setenv("MLFLOW_UC_CATALOG", "team_catalog") + monkeypatch.setenv("MLFLOW_UC_SCHEMA", "observability") + monkeypatch.setenv("MLFLOW_UC_TABLE_PREFIX", "agent_prod") + monkeypatch.setenv("MLFLOW_TRACING_SQL_WAREHOUSE_ID", "warehouse-from-env") + monkeypatch.setenv( + "MLFLOW_EXPERIMENT_NAME", "/Users/user@example.com/agents-on-apps-unique" + ) + + args = quickstart._parse_args([]) + + assert args.mlflow_catalog == "team_catalog" + assert args.mlflow_schema == "observability" + assert args.mlflow_table_prefix == "agent_prod" + assert args.mlflow_warehouse_id == "warehouse-from-env" + assert args.mlflow_experiment_name == ( + "/Users/user@example.com/agents-on-apps-unique" + ) + + def test_persists_six_values_and_warehouse_resource_to_bundle_and_app_yaml( + self, tmp_path + ): (tmp_path / "databricks.yml").write_text(MINIMAL_YML) - (tmp_path / ".env").write_text("MLFLOW_EXPERIMENT_ID=12345\n") - existing_exp = MagicMock() - existing_exp.name = "/Users/test/agents-on-apps" - mock_w = _mock_workspace_client(get_experiment_result=existing_exp) - with patch("quickstart.get_workspace_client", return_value=mock_w): - _, exp_id = create_mlflow_experiment("DEFAULT", "test@example.com") + (tmp_path / "app.yaml").write_text( + "command: [\"uv\", \"run\", \"start-app\"]\n" + "env:\n" + " - name: MLFLOW_EXPERIMENT_ID\n" + " valueFrom: experiment\n" + ) + config = quickstart.MlflowTraceConfig( + "/Users/user@example.com/agents-on-apps", + "12345", + "0123456789abcdef", + "main", + "agent_traces", + "agents_on_apps", + "main.agent_traces.agents_on_apps_otel_spans", + ) - # The test verifies create_mlflow_experiment returns the ID correctly; - # the caller (main) is responsible for calling update_databricks_yml_experiment - assert exp_id == "12345" - # Explicitly verify update_databricks_yml_experiment works after reuse - update_databricks_yml_experiment(exp_id) - content = (tmp_path / "databricks.yml").read_text() - assert 'experiment_id: "12345"' in content + quickstart.update_mlflow_trace_runtime_config(config) + + _, bundle = quickstart._load_yml(tmp_path / "databricks.yml") + app = next(iter(bundle["resources"]["apps"].values())) + env_by_name = {entry["name"]: entry for entry in app["config"]["env"]} + assert env_by_name["MLFLOW_EXPERIMENT_ID"] == { + "name": "MLFLOW_EXPERIMENT_ID", + "value_from": "experiment", + } + assert env_by_name["MLFLOW_TRACING_SQL_WAREHOUSE_ID"] == { + "name": "MLFLOW_TRACING_SQL_WAREHOUSE_ID", + "value_from": "mlflow-tracing-warehouse", + } + assert env_by_name["MLFLOW_UC_CATALOG"]["value"] == "main" + assert env_by_name["MLFLOW_UC_SCHEMA"]["value"] == "agent_traces" + assert env_by_name["MLFLOW_UC_TABLE_PREFIX"]["value"] == "agents_on_apps" + assert env_by_name["MLFLOW_OTEL_SPANS_TABLE"]["value"] == ( + "main.agent_traces.agents_on_apps_otel_spans" + ) + resources = {entry["name"]: entry for entry in app["resources"]} + assert resources["experiment"]["experiment"]["experiment_id"] == "12345" + assert resources["mlflow-tracing-warehouse"] == { + "name": "mlflow-tracing-warehouse", + "sql_warehouse": { + "id": "0123456789abcdef", + "permission": "CAN_USE", + }, + } + + _, app_yaml = quickstart._load_yml(tmp_path / "app.yaml") + app_env = {entry["name"]: entry for entry in app_yaml["env"]} + assert app_env["MLFLOW_EXPERIMENT_ID"]["valueFrom"] == "experiment" + assert app_env["MLFLOW_TRACING_SQL_WAREHOUSE_ID"]["valueFrom"] == ( + "mlflow-tracing-warehouse" + ) + assert app_env["MLFLOW_OTEL_SPANS_TABLE"]["value"] == ( + "main.agent_traces.agents_on_apps_otel_spans" + ) class TestUpdateDatabricksYmlAppName: diff --git a/.scripts/sync-scripts.py b/.scripts/sync-scripts.py index fe90f39d..965f3992 100644 --- a/.scripts/sync-scripts.py +++ b/.scripts/sync-scripts.py @@ -4,7 +4,8 @@ The source of truth is .scripts/source/. This script copies: - Shared Python scripts (verbatim copy) into each template's `scripts/` - or `agent_server/` directory, respecting per-template `exclude_scripts`. + or `agent_server/` directory, respecting per-template `exclude_scripts` + and explicit `script_sources` variants. - GitHub Actions workflows (with `{{BUNDLE_NAME}}` substitution) into each template's `.github/workflows/` directory, gated on the `has_actions` field in templates.py. @@ -14,9 +15,10 @@ """ import shutil +import re from pathlib import Path -from templates import TEMPLATES +from templates import MLFLOW_DEPENDENCY, MLFLOW_UC_DEFAULTS, TEMPLATES SCRIPT_DIR = Path(__file__).parent.resolve() REPO_ROOT = SCRIPT_DIR.parent @@ -37,9 +39,95 @@ ] +def sync_mlflow_uc_configuration(template: str, config: dict) -> list[str]: + """Keep Python agent dependencies and deployed UC tracing config aligned.""" + template_dir = REPO_ROOT / template + changed: list[str] = [] + + pyproject = template_dir / "pyproject.toml" + pyproject_content = pyproject.read_text() + pinned = re.sub( + r'"mlflow(?:\[databricks\])?[^\"]*"', + f'"{MLFLOW_DEPENDENCY}"', + pyproject_content, + count=1, + ) + if pinned == pyproject_content and MLFLOW_DEPENDENCY not in pyproject_content: + raise RuntimeError(f"Could not find the MLflow dependency in {pyproject}") + if pinned != pyproject_content: + pyproject.write_text(pinned) + changed.append("pyproject.toml") + + bundle_path = template_dir / "databricks.yml" + bundle = bundle_path.read_text() + if "MLFLOW_TRACING_SQL_WAREHOUSE_ID" not in bundle: + experiment_env = ( + " - name: MLFLOW_EXPERIMENT_ID\n" + " value_from: \"experiment\"\n" + ) + trace_env = ( + " - name: MLFLOW_TRACING_SQL_WAREHOUSE_ID\n" + " value_from: \"mlflow-tracing-warehouse\"\n" + f" - name: MLFLOW_UC_CATALOG\n value: \"{MLFLOW_UC_DEFAULTS['MLFLOW_UC_CATALOG']}\"\n" + f" - name: MLFLOW_UC_SCHEMA\n value: \"{MLFLOW_UC_DEFAULTS['MLFLOW_UC_SCHEMA']}\"\n" + f" - name: MLFLOW_UC_TABLE_PREFIX\n value: \"{MLFLOW_UC_DEFAULTS['MLFLOW_UC_TABLE_PREFIX']}\"\n" + f" - name: MLFLOW_OTEL_SPANS_TABLE\n value: \"{MLFLOW_UC_DEFAULTS['MLFLOW_OTEL_SPANS_TABLE']}\"\n" + ) + if experiment_env not in bundle: + raise RuntimeError(f"Could not find the MLflow experiment env binding in {bundle_path}") + bundle = bundle.replace(experiment_env, experiment_env + trace_env, 1) + + experiment_resource = re.search( + r"(?P - name: ['\"]experiment['\"]\n" + r" experiment:\n" + r" experiment_id: .*\n" + r" permission: ['\"]CAN_MANAGE['\"]\n)", + bundle, + ) + if not experiment_resource: + raise RuntimeError(f"Could not find the MLflow experiment resource in {bundle_path}") + warehouse_resource = ( + " - name: 'mlflow-tracing-warehouse'\n" + " sql_warehouse:\n" + " id: \"\"\n" + " permission: 'CAN_USE'\n" + ) + bundle = ( + bundle[: experiment_resource.end()] + + warehouse_resource + + bundle[experiment_resource.end() :] + ) + bundle_path.write_text(bundle) + changed.append("databricks.yml") + + if config.get("has_app_yaml"): + app_path = template_dir / "app.yaml" + app = app_path.read_text() + if "MLFLOW_TRACING_SQL_WAREHOUSE_ID" not in app: + experiment_env = ( + " - name: MLFLOW_EXPERIMENT_ID\n" + " valueFrom: \"experiment\"\n" + ) + trace_env = ( + " - name: MLFLOW_TRACING_SQL_WAREHOUSE_ID\n" + " valueFrom: \"mlflow-tracing-warehouse\"\n" + f" - name: MLFLOW_UC_CATALOG\n value: \"{MLFLOW_UC_DEFAULTS['MLFLOW_UC_CATALOG']}\"\n" + f" - name: MLFLOW_UC_SCHEMA\n value: \"{MLFLOW_UC_DEFAULTS['MLFLOW_UC_SCHEMA']}\"\n" + f" - name: MLFLOW_UC_TABLE_PREFIX\n value: \"{MLFLOW_UC_DEFAULTS['MLFLOW_UC_TABLE_PREFIX']}\"\n" + f" - name: MLFLOW_OTEL_SPANS_TABLE\n value: \"{MLFLOW_UC_DEFAULTS['MLFLOW_OTEL_SPANS_TABLE']}\"\n" + ) + if experiment_env not in app: + raise RuntimeError(f"Could not find the MLflow experiment env binding in {app_path}") + app_path.write_text(app.replace(experiment_env, experiment_env + trace_env, 1)) + changed.append("app.yaml") + + return changed + + def sync_scripts(template: str, config: dict) -> list[str]: """Copy shared Python scripts into the template. Returns list of synced names.""" exclude = config.get("exclude_scripts", []) + source_overrides = config.get("script_sources", {}) scripts = [(s, d) for s, d in SCRIPTS_TO_SYNC if s not in exclude] synced: list[str] = [] for script, dest_subdir in scripts: @@ -47,7 +135,12 @@ def sync_scripts(template: str, config: dict) -> list[str]: if not dest_dir.exists(): print(f" Warning: {dest_dir} does not exist, skipping {script}") continue - shutil.copy2(SOURCE_DIR / script, dest_dir / script) + source_path = SOURCE_DIR / source_overrides.get(script, script) + if not source_path.is_file(): + raise RuntimeError( + f"Configured source for {template}/{script} does not exist: {source_path}" + ) + shutil.copy2(source_path, dest_dir / script) synced.append(script) return synced @@ -94,7 +187,8 @@ def main(): for template, config in TEMPLATES.items(): scripts_synced = sync_scripts(template, config) workflows_synced = sync_workflows(template, config) - all_synced = scripts_synced + workflows_synced + config_synced = sync_mlflow_uc_configuration(template, config) + all_synced = scripts_synced + workflows_synced + config_synced if all_synced: print(f"Syncing {template}... ({', '.join(all_synced)})") else: diff --git a/.scripts/templates.py b/.scripts/templates.py index 91bb4d32..f32229b4 100644 --- a/.scripts/templates.py +++ b/.scripts/templates.py @@ -1,21 +1,32 @@ """Shared template configuration for sync scripts.""" +MLFLOW_DEPENDENCY = "mlflow[databricks]>=3.14.0,<4" +MLFLOW_UC_DEFAULTS = { + "MLFLOW_UC_CATALOG": "main", + "MLFLOW_UC_SCHEMA": "agent_traces", + "MLFLOW_UC_TABLE_PREFIX": "agents_on_apps", + "MLFLOW_OTEL_SPANS_TABLE": "main.agent_traces.agents_on_apps_otel_spans", +} + TEMPLATES = { "agent-langgraph": { "sdk": "langgraph", "bundle_name": "agent_langgraph", "has_actions": True, + "has_app_yaml": True, }, "agent-langgraph-advanced": { "sdk": "langgraph", "bundle_name": "agent_langgraph_advanced", "has_memory": True, "has_actions": True, + "has_app_yaml": True, }, "agent-openai-agents-sdk": { "sdk": "openai", "bundle_name": "agent_openai_agents_sdk", "has_actions": True, + "has_app_yaml": True, }, "agent-openai-agents-sdk-multiagent": { "sdk": "openai", @@ -27,15 +38,20 @@ "bundle_name": "agent_openai_advanced", "has_memory": True, "has_actions": True, + "has_app_yaml": True, }, "agent-non-conversational": { "sdk": "langgraph", "bundle_name": "agent_non_conversational", "exclude_scripts": ["start_app.py", "evaluate_agent.py", "preflight.py"], "exclude_load_testing": True, + "has_app_yaml": True, }, "agent-migration-from-model-serving": { "sdk": ["langgraph", "openai"], "bundle_name": "agent_migration", + "script_sources": { + "preflight.py": "agent-migration-from-model-serving/preflight.py", + }, }, } diff --git a/.scripts/test_sync_scripts.py b/.scripts/test_sync_scripts.py new file mode 100644 index 00000000..18c3bc65 --- /dev/null +++ b/.scripts/test_sync_scripts.py @@ -0,0 +1,141 @@ +"""Regression tests for shared script synchronization.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SYNC_SCRIPT = REPO_ROOT / ".scripts" / "sync-scripts.py" +MIGRATION_TEMPLATE = "agent-migration-from-model-serving" + + +def _load_module(path: Path, name: str) -> ModuleType: + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def synced_migration_preflight(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.syspath_prepend(str(SYNC_SCRIPT.parent)) + sync = _load_module(SYNC_SCRIPT, "sync_scripts_under_test") + template_root = tmp_path / MIGRATION_TEMPLATE + (template_root / "scripts").mkdir(parents=True) + + config = { + **sync.TEMPLATES[MIGRATION_TEMPLATE], + "exclude_scripts": [ + source_name + for source_name, _ in sync.SCRIPTS_TO_SYNC + if source_name != "preflight.py" + ], + } + monkeypatch.setattr(sync, "REPO_ROOT", tmp_path) + assert sync.sync_scripts(MIGRATION_TEMPLATE, config) == ["preflight.py"] + + generated_path = template_root / "scripts" / "preflight.py" + return _load_module(generated_path, "generated_migration_preflight") + + +def test_migration_preflight_has_explicit_canonical_owner() -> None: + sys.path.insert(0, str(SYNC_SCRIPT.parent)) + try: + sync = _load_module(SYNC_SCRIPT, "sync_scripts_ownership_test") + finally: + sys.path.remove(str(SYNC_SCRIPT.parent)) + + assert sync.TEMPLATES[MIGRATION_TEMPLATE].get("script_sources") == { + "preflight.py": "agent-migration-from-model-serving/preflight.py" + } + + +def test_sync_preserves_deployment_tracing_validation( + synced_migration_preflight: ModuleType, +) -> None: + preflight = synced_migration_preflight + assert callable(getattr(preflight, "verify_deployment_trace_resources", None)) + config = { + "MLFLOW_EXPERIMENT_ID": "123", + "MLFLOW_TRACING_SQL_WAREHOUSE_ID": "0123456789abcdef", + "MLFLOW_UC_CATALOG": "main", + "MLFLOW_UC_SCHEMA": "agent_traces", + "MLFLOW_UC_TABLE_PREFIX": "agents_on_apps", + "MLFLOW_OTEL_SPANS_TABLE": "main.agent_traces.agents_on_apps_otel_spans", + } + mlflow_client = SimpleNamespace(get_experiment=lambda _experiment_id: None) + + def missing_warehouse(_warehouse_id: str): + raise RuntimeError("warehouse missing") + + workspace_client = SimpleNamespace( + warehouses=SimpleNamespace(get=missing_warehouse) + ) + + with pytest.raises(RuntimeError, match="Deployment tracing preflight failed"): + preflight.verify_deployment_trace_resources( + config, + mlflow_client=mlflow_client, + workspace_client=workspace_client, + ) + + +def test_sync_preserves_exact_smoke_trace_retrieval( + synced_migration_preflight: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + preflight = synced_migration_preflight + assert callable(getattr(preflight, "verify_smoke_trace", None)) + foreign = SimpleNamespace( + info=SimpleNamespace( + timestamp_ms=200, + trace_id="foreign-trace", + trace_metadata={"appkit.request.id": "another-request"}, + ) + ) + monkeypatch.setattr( + preflight.mlflow, "get_tracking_uri", lambda: "sqlite:///test.db" + ) + monkeypatch.setattr(preflight.mlflow, "search_traces", lambda **_kwargs: [foreign]) + monkeypatch.setattr( + preflight.mlflow, + "get_trace", + lambda _trace_id, flush: pytest.fail("foreign trace must not be retrieved"), + ) + + with pytest.raises(RuntimeError, match="exact smoke trace was not retrievable"): + preflight.verify_smoke_trace( + experiment_id="123", + started_ms=100, + request_id="expected-request", + timeout_seconds=0, + ) + + +def test_sync_preserves_offline_preflight_mode( + synced_migration_preflight: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + preflight = synced_migration_preflight + assert callable(getattr(preflight, "run_offline_test", None)) + offline_calls: list[bool] = [] + monkeypatch.setattr(sys, "argv", ["preflight.py", "--offline-test"]) + monkeypatch.setattr( + preflight, "run_offline_test", lambda: offline_calls.append(True) + ) + monkeypatch.setattr( + preflight, + "find_free_port", + lambda: pytest.fail("offline mode must not start the server"), + ) + + preflight.main() + + assert offline_calls == [True] diff --git a/.scripts/trace-conformance/__init__.py b/.scripts/trace-conformance/__init__.py new file mode 100644 index 00000000..11600470 --- /dev/null +++ b/.scripts/trace-conformance/__init__.py @@ -0,0 +1,29 @@ +from contract import SpanManifest, TraceManifest, assert_trace_contract +from discovery import ( + AgentTemplate, + assert_template_policy, + discover_agentic_templates, +) +from normalize import ( + load_trace_manifest, + normalize_appkit_otel_trace, + normalize_mlflow_core_trace, + normalize_python_mlflow_trace, + normalize_uc_rows, + write_trace_manifest, +) + +__all__ = [ + "AgentTemplate", + "SpanManifest", + "TraceManifest", + "assert_template_policy", + "assert_trace_contract", + "discover_agentic_templates", + "load_trace_manifest", + "normalize_appkit_otel_trace", + "normalize_mlflow_core_trace", + "normalize_python_mlflow_trace", + "normalize_uc_rows", + "write_trace_manifest", +] diff --git a/.scripts/trace-conformance/contract.py b/.scripts/trace-conformance/contract.py new file mode 100644 index 00000000..541607b0 --- /dev/null +++ b/.scripts/trace-conformance/contract.py @@ -0,0 +1,278 @@ +import re +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class SpanManifest: + name: str + span_type: str + span_id: str + parent_span_id: str | None + inputs: Any + outputs: Any + status: str + latency_ms: float + model: str | None + provider: str | None + usage: dict[str, int | float] + cost_usd: float | None + cost_available: bool + links: list[dict[str, str]] + attributes: dict[str, Any] + + +@dataclass +class TraceManifest: + template: str + trace_id: str + spans: list[SpanManifest] = field(default_factory=list) + + +_SPAN_TYPES = { + "AGENT", + "CHAIN", + "CHAT_MODEL", + "EMBEDDING", + "LLM", + "MEMORY", + "PARSER", + "RETRIEVER", + "TOOL", +} +_MODEL_TYPES = {"CHAT_MODEL", "LLM"} +_TERMINAL_STATUSES = {"OK", "SUCCESS", "ERROR", "CANCELLED"} +_IDENTITY_FIELDS = ("app_id", "user_id", "session_id") +_USAGE_FIELDS = ("input_tokens", "output_tokens", "total_tokens") +_SECRET_KEYS = { + "accesstoken", + "apikey", + "authorization", + "clientsecret", + "cookie", + "credential", + "credentials", + "databrickstoken", + "password", + "refreshtoken", + "secret", + "setcookie", + "token", + "xapikey", +} +_REDACTED = "[REDACTED]" + + +def _fail( + trace: TraceManifest, span: SpanManifest, field_name: str, detail: str +) -> None: + raise AssertionError( + f"template={trace.template} span={span.name} field={field_name}: {detail}" + ) + + +def _has_value(value: Any) -> bool: + return value is not None and value != "" and value != {} and value != [] + + +def _normalized_key(value: str) -> str: + return re.sub(r"[^a-z0-9]", "", value.lower()) + + +def _assert_redacted(trace: TraceManifest, span: SpanManifest, value: Any) -> None: + if isinstance(value, dict): + for key, nested in value.items(): + if _normalized_key(str(key)) in _SECRET_KEYS and nested != _REDACTED: + _fail(trace, span, "credentials", f"{key} is not redacted") + _assert_redacted(trace, span, nested) + elif isinstance(value, (list, tuple)): + for nested in value: + _assert_redacted(trace, span, nested) + elif isinstance(value, str): + leaked = re.search( + r"(?i)\b(?:authorization|api[ _-]?key|password|secret|token|credentials?)\b" + r"\s*(?::|=|is)?\s+(?:" + r"Bearer\s+(?P[^\s,;}]+)" + r"|(?P(?!Bearer\b)[^\s,;}]+))", + value, + ) + captured = ( + leaked.group("bearer_value") or leaked.group("plain_value") + if leaked + else None + ) + if captured and captured != _REDACTED: + _fail( + trace, + span, + "credentials", + "captured text contains an unredacted secret", + ) + + +def _assert_usage(trace: TraceManifest, span: SpanManifest) -> None: + for key in _USAGE_FIELDS: + value = span.usage.get(key) + if not isinstance(value, (int, float)) or isinstance(value, bool) or value < 0: + _fail(trace, span, f"usage.{key}", "must be a non-negative number") + if span.usage["total_tokens"] < max( + span.usage["input_tokens"], span.usage["output_tokens"] + ): + _fail(trace, span, "usage.total_tokens", "is smaller than a component") + + +def _assert_cost(trace: TraceManifest, span: SpanManifest) -> None: + if not isinstance(span.cost_available, bool): + _fail(trace, span, "cost_available", "must explicitly be true or false") + if span.cost_available: + if ( + not isinstance(span.cost_usd, (int, float)) + or isinstance(span.cost_usd, bool) + or span.cost_usd < 0 + ): + _fail(trace, span, "cost_usd", "available cost must be non-negative") + elif span.cost_usd is not None: + _fail(trace, span, "cost_usd", "unavailable cost must not be reported as zero") + + +def _assert_remote(trace: TraceManifest, span: SpanManifest) -> None: + remote_trace_id = span.attributes.get("remote_trace_id") + if not remote_trace_id: + return + remote_span_id = span.attributes.get("remote_span_id") + if not remote_span_id: + _fail(trace, span, "remote_span_id", "remote trace has no root span identity") + if span.attributes.get("remote_lifecycle_complete") is not True: + _fail( + trace, span, "remote_lifecycle_complete", "remote lifecycle is incomplete" + ) + if remote_trace_id == trace.trace_id: + return + expected = {"trace_id": remote_trace_id, "span_id": remote_span_id} + if expected not in span.links: + _fail( + trace, span, "links", "orphan remote trace is neither continued nor linked" + ) + + +def assert_trace_contract(trace: TraceManifest) -> None: + if not isinstance(trace.template, str) or not trace.template: + raise AssertionError( + "template= span= field=template: missing template" + ) + if not isinstance(trace.trace_id, str) or not trace.trace_id: + raise AssertionError( + f"template={trace.template} span= field=trace_id: missing trace identity" + ) + if not trace.spans: + raise AssertionError( + f"template={trace.template} span= field=spans: trace has no spans" + ) + + parentless = [span for span in trace.spans if span.parent_span_id is None] + if len(parentless) != 1 or parentless[0].span_type != "AGENT": + culprit = parentless[-1] if parentless else trace.spans[0] + _fail( + trace, culprit, "AGENT root", "trace must have exactly one parentless AGENT" + ) + root = parentless[0] + + span_ids: set[str] = set() + for span in trace.spans: + if not isinstance(span.name, str) or not span.name: + _fail(trace, span, "name", "missing span name") + if span.name != span.name.strip() or any( + ord(character) < 32 for character in span.name + ): + _fail(trace, span, "name", "span name is not canonical") + if span.span_type not in _SPAN_TYPES: + _fail( + trace, + span, + "span_type", + f"unsupported semantic type {span.span_type!r}", + ) + if not isinstance(span.span_id, str) or not span.span_id: + _fail(trace, span, "span_id", "missing span identity") + if span.span_id in span_ids: + _fail(trace, span, "span_id", "duplicate span identity") + span_ids.add(span.span_id) + + if not _has_value(span.inputs): + _fail(trace, span, "inputs", "captured inputs are missing") + if not _has_value(span.outputs): + _fail(trace, span, "outputs", "captured outputs are missing") + if span.status not in _TERMINAL_STATUSES: + _fail(trace, span, "status", "span is not finalized with a terminal status") + if ( + not isinstance(span.latency_ms, (int, float)) + or isinstance(span.latency_ms, bool) + or span.latency_ms < 0 + ): + _fail(trace, span, "latency_ms", "missing or invalid latency") + if span.status == "ERROR" and not ( + isinstance(span.outputs, dict) + and _has_value(span.outputs.get("partial_output")) + ): + _fail(trace, span, "outputs", "failed span must retain partial_output") + _assert_cost(trace, span) + _assert_remote(trace, span) + _assert_redacted(trace, span, span.inputs) + _assert_redacted(trace, span, span.outputs) + _assert_redacted(trace, span, span.attributes) + + for span in trace.spans: + if span is not root and span.parent_span_id not in span_ids: + _fail(trace, span, "parent_span_id", "span has an orphan parent") + + spans_by_id = {span.span_id: span for span in trace.spans} + for span in trace.spans: + ancestry: set[str] = set() + current = span + while current.parent_span_id is not None: + if current.span_id in ancestry: + _fail(trace, span, "parent_span_id", "span ancestry contains a cycle") + ancestry.add(current.span_id) + current = spans_by_id[current.parent_span_id] + + model_spans = [span for span in trace.spans if span.span_type in _MODEL_TYPES] + if not model_spans: + _fail(trace, root, "semantic child", "trace has no model child") + for span in model_spans: + if not span.model: + _fail(trace, span, "model", "model identity is missing") + if not span.provider: + _fail(trace, span, "provider", "provider identity is missing") + _assert_usage(trace, span) + if span.attributes.get("streaming") is True: + for key in ("ttft_ms", "stream_duration_ms"): + value = span.attributes.get(key) + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or value < 0 + ): + _fail(trace, span, key, "stream timing is missing or invalid") + + for field_name in _IDENTITY_FIELDS: + if not _has_value(root.attributes.get(field_name)): + _fail(trace, root, field_name, "request identity is missing") + + _assert_usage(trace, root) + for key in _USAGE_FIELDS: + expected = sum(span.usage[key] for span in model_spans) + if root.usage[key] != expected: + _fail( + trace, + root, + f"usage.{key}", + f"aggregate {root.usage[key]!r} does not equal descendant total {expected!r}", + ) + + expected_cost_available = all(span.cost_available for span in model_spans) + if root.cost_available != expected_cost_available: + _fail(trace, root, "cost_available", "does not match descendant availability") + if expected_cost_available: + expected_cost = sum(float(span.cost_usd) for span in model_spans) + if abs(float(root.cost_usd) - expected_cost) > 1e-12: + _fail(trace, root, "cost_usd", "does not equal descendant cost total") diff --git a/.scripts/trace-conformance/discovery.py b/.scripts/trace-conformance/discovery.py new file mode 100644 index 00000000..073ce55b --- /dev/null +++ b/.scripts/trace-conformance/discovery.py @@ -0,0 +1,394 @@ +import re +import os +import json +from dataclasses import dataclass +from pathlib import Path + + +_SOURCE_SUFFIXES = {".py", ".ts", ".tsx", ".js", ".jsx"} +_CONFIG_SUFFIXES = {".json", ".yaml", ".yml", ".tmpl", ".toml"} +_EXCLUDED_PARTS = { + ".git", + ".pytest_cache", + ".venv", + "__pycache__", + "build", + "coverage", + "dist", + "node_modules", + "test", + "tests", +} +_UC_ENV = { + "MLFLOW_EXPERIMENT_ID", + "MLFLOW_TRACING_SQL_WAREHOUSE_ID", + "MLFLOW_UC_CATALOG", + "MLFLOW_UC_SCHEMA", + "MLFLOW_UC_TABLE_PREFIX", + "MLFLOW_OTEL_SPANS_TABLE", +} + + +@dataclass(frozen=True) +class AgentTemplate: + name: str + path: Path + signals: tuple[str, ...] + has_uc_resources: bool + has_local_conformance: bool + has_deployed_verification: bool + local_test_command: tuple[str, ...] | None = None + proof_owner: str | None = None + + +def _read(path: Path) -> str: + try: + if path.stat().st_size > 1_000_000: + return "" + return path.read_text(errors="ignore") + except (OSError, UnicodeError): + return "" + + +def _production_source(template: Path) -> str: + chunks = [] + for path in _iter_files(template, _SOURCE_SUFFIXES, exclude_tests=True): + chunks.append(_read(path)) + return "\n".join(chunks) + + +def _signals(source: str) -> tuple[str, ...]: + signals = [] + if re.search(r"\bAgentServer\b", source): + signals.append("agent-server") + if re.search( + r"\b(?:Agent|ResponsesAgent|ChatAgent|createAgent|createReactAgent|create_react_agent)\s*\(", + source, + ): + signals.append("agent-constructor") + explicit_agent_endpoint = re.search( + r"(?:\.responses\.create\s*\(|/agent/v\d+/|agents/[\w./-]+)", source + ) + trace_returning_invocation = re.search(r"/invocations\b", source) and re.search( + r"\b(?:agent|return_trace|trace_id|traceId|request_id|requestId)\b", + source, + re.IGNORECASE, + ) + if explicit_agent_endpoint or trace_returning_invocation: + signals.append("agent-endpoint") + if ( + re.search(r"\bwhile\b", source) + and re.search(r"\b(?:model|llm)\b", source, re.IGNORECASE) + and re.search(r"\btool(?:_calls?)?\b|\.execute\s*\(", source, re.IGNORECASE) + ): + signals.append("model-tool-loop") + if re.search( + r"\b(?:retriev\w*|vector[_ ]?search|similaritySearch)\b", source, re.IGNORECASE + ) and re.search(r"\b(?:generate|model|llm|chat)\b", source, re.IGNORECASE): + signals.append("retrieval-generation") + return tuple(signals) + + +def _production_signals(template: Path) -> tuple[str, ...]: + observed = { + signal + for path in _iter_files(template, _SOURCE_SUFFIXES, exclude_tests=True) + for signal in _signals(_read(path)) + } + order = ( + "agent-server", + "agent-constructor", + "agent-endpoint", + "model-tool-loop", + "retrieval-generation", + ) + return tuple(signal for signal in order if signal in observed) + + +def _all_text(template: Path, *, suffixes: set[str]) -> str: + return "\n".join(_read(path) for path in _iter_files(template, suffixes)) + + +def _iter_files(root: Path, suffixes: set[str], *, exclude_tests: bool = False): + for directory, names, files in os.walk(root): + names[:] = [ + name + for name in names + if ( + name not in _EXCLUDED_PARTS + or (name in {"test", "tests"} and not exclude_tests) + ) + and not name.startswith(".") + and (not exclude_tests or name not in {"test", "tests"}) + ] + base = Path(directory) + for name in files: + path = base / name + if path.suffix in suffixes: + yield path + + +def _has_uc_resources(template: Path) -> bool: + config = _all_text(template, suffixes=_CONFIG_SUFFIXES) + normalized = config.lower() + return ( + all(variable.lower() in normalized for variable in _UC_ENV) + and re.search(r"\bexperiment", config, re.IGNORECASE) is not None + and re.search(r"\b(?:sql_)?warehouse", config, re.IGNORECASE) is not None + ) + + +def _test_files(template: Path) -> list[tuple[Path, str]]: + files = [] + for path in _iter_files(template, _SOURCE_SUFFIXES): + relative = path.relative_to(template).as_posix().lower() + if "test" not in relative: + continue + files.append((path, _read(path))) + return files + + +def _has_local_conformance(template: Path) -> bool: + for path, source in _test_files(template): + relative = path.relative_to(template).as_posix().lower() + if "deployed" in relative or "e2e" in relative: + continue + direct_contract = re.search( + r"assert_?Trace_?Contract", source, re.IGNORECASE + ) and re.search(r"mock|deterministic|inmemory", source, re.IGNORECASE) + real_trace_test = re.search( + r"search_traces|InMemoryTraceManager|withAgentRequestTrace|InMemorySpanExporter", + source, + ) and re.search( + r"monkeypatch|MockTransport|jest\.mock|vi\.mock|inmemory|stub", + source, + re.IGNORECASE, + ) + sdk_hook_trace_test = "registerOnSpanEndHook" in source and re.search( + r"spyOn|mockImplementation|loopback", source, re.IGNORECASE + ) + if direct_contract or real_trace_test or sdk_hook_trace_test: + return True + return False + + +def _has_deployed_verification(template: Path) -> bool: + for path, source in _test_files(template): + relative = path.relative_to(template).as_posix().lower() + deployed_test = ( + "deployed" in relative + or "e2e" in relative + or re.search(r"\b(?:def|test)\s+test_deployed", source) is not None + ) + deployment_preflight = ( + "verify_deployment_trace_resources" in source + and "verify_smoke_trace" in source + ) + if not deployed_test and not deployment_preflight: + continue + has_invoke = re.search(r"invoke|request|fetch|post", source, re.IGNORECASE) + has_trace = re.search(r"trace_id|traceId|get_trace|getTrace", source) + has_uc = re.search( + r"otel_spans|otelSpans|query_otel|unity[_ ]catalog|\bUC\b", + source, + re.IGNORECASE, + ) + if has_invoke and has_trace and has_uc: + return True + return False + + +def _local_test_command(template: Path) -> tuple[str, ...] | None: + if (template / "pyproject.toml").exists() and ( + template / "tests/test_tracing.py" + ).exists(): + command = [ + "uv", + "run", + "--offline", + "--frozen", + "--project", + template.name, + "pytest", + f"{template.name}/tests/test_tracing.py", + "-v", + ] + if "pytest-xdist" in (template / "pyproject.toml").read_text(): + command.append("-n0") + return tuple(command) + nested_python_trace_tests = sorted( + template.glob("pipelines/*/tests/test_tracing.py") + ) + if nested_python_trace_tests: + repository_root = template.parent + integration_project = repository_root / ".scripts" / "agent-integration-tests" + return ( + "uv", + "run", + "--offline", + "--frozen", + "--project", + integration_project.relative_to(repository_root).as_posix(), + "pytest", + nested_python_trace_tests[0].relative_to(repository_root).as_posix(), + "-v", + ) + standalone_python_trace_test = template / "tests" / "test_trace_conformance.py" + if standalone_python_trace_test.exists(): + repository_root = template.parent + integration_project = repository_root / ".scripts" / "agent-integration-tests" + return ( + "uv", + "run", + "--offline", + "--frozen", + "--project", + integration_project.relative_to(repository_root).as_posix(), + "pytest", + standalone_python_trace_test.relative_to(repository_root).as_posix(), + "-v", + ) + tracing_tests = [ + path + for path in ( + template / "server" / "tests" / "tracing.test.ts", + template / "server" / "tests" / "tracing-real-ai-sdk.test.ts", + ) + if path.exists() + ] + if (template / "package.json").exists() and tracing_tests: + return ( + "npm", + "test", + "--", + *(path.relative_to(template).as_posix() for path in tracing_tests), + ) + proxy_trace_test = ( + template / "tests" / "routes" / "trace-id-capture.api-proxy.test.ts" + ) + if (template / "package.json").exists() and proxy_trace_test.exists(): + return ( + "npm", + "run", + "test:ephemeral", + "--", + proxy_trace_test.relative_to(template).as_posix(), + "--project=routes-api-proxy", + ) + if (template / "package.json").exists() and ( + template / "tests/framework/tracing.test.ts" + ).exists(): + return ( + "npm", + "test", + "--", + "--runInBand", + "tests/framework/tracing.test.ts", + "tests/framework/endpoints.test.ts", + ) + return None + + +def _generated_appkit_proof_owner(template: Path) -> str | None: + package_path = template / "package.json" + manifest_path = template / "appkit.plugins.json" + server_path = template / "server" / "server.ts" + if not all(path.exists() for path in (package_path, manifest_path, server_path)): + return None + try: + package = json.loads(_read(package_path)) + manifest = json.loads(_read(manifest_path)) + except (TypeError, ValueError): + return None + agents = manifest.get("plugins", {}).get("agents", {}) + server = _read(server_path) + if not ( + package.get("dependencies", {}).get("@databricks/appkit") + and agents.get("package") == "@databricks/appkit" + and agents.get("requiredByTemplate") is True + and re.search(r"\bcreateApp\s*\(", server) + and re.search(r"\bagents\s*\(", server) + ): + return None + configured_owner = os.environ.get("APPKIT_SOURCE_ROOT") + if configured_owner: + owner_roots = [Path(configured_owner)] + else: + workspace_root = template.parent.parent + owner_roots = sorted( + (path for path in workspace_root.iterdir() if path.is_dir()), + key=lambda path: path.name, + ) + matches: list[Path] = [] + for owner_root in owner_roots: + owner_suite = ( + owner_root + / "packages/appkit/src/plugins/agents/tests/trace-conformance.integration.test.ts" + ) + owner_generator = owner_root / "tools/generate-app-templates.ts" + if ( + owner_suite.exists() + and owner_generator.exists() + and f'name: "{template.name}"' in _read(owner_generator) + ): + matches.append(owner_root.resolve()) + if configured_owner and matches: + return str(matches[0]) + return str(matches[0]) if len(matches) == 1 else None + + +def is_trace_policy_candidate(template: AgentTemplate) -> bool: + """Admit every executable agent behavior, without consulting proof.""" + return bool(template.signals) + + +def discover_agentic_templates(root: Path | str) -> list[AgentTemplate]: + root = Path(root).resolve() + discovered = [] + for template in sorted( + path + for path in root.iterdir() + if path.is_dir() and not path.name.startswith(".") + ): + signals = _production_signals(template) + if not signals: + continue + proof_owner = _generated_appkit_proof_owner(template) + local_test_command = _local_test_command(template) + if proof_owner and local_test_command is None: + local_test_command = ( + "__appkit_generated_owner__", + proof_owner, + template.name, + ) + discovered.append( + AgentTemplate( + name=template.name, + path=template, + signals=signals, + has_uc_resources=_has_uc_resources(template), + has_local_conformance=_has_local_conformance(template), + has_deployed_verification=( + _has_deployed_verification(template) or proof_owner is not None + ), + local_test_command=local_test_command, + proof_owner=proof_owner, + ) + ) + return discovered + + +def assert_template_policy(templates: list[AgentTemplate]) -> None: + failures = [] + for template in templates: + missing = [] + if not template.has_uc_resources: + missing.append("UC resources") + if not template.has_local_conformance and template.local_test_command is None: + missing.append("deterministic local conformance") + if not template.has_deployed_verification and not template.proof_owner: + missing.append("deployed verification") + if missing: + failures.append(f"{template.name}: missing {', '.join(missing)}") + if failures: + raise AssertionError("Agent template policy failed:\n" + "\n".join(failures)) diff --git a/.scripts/trace-conformance/normalize.py b/.scripts/trace-conformance/normalize.py new file mode 100644 index 00000000..3860ed16 --- /dev/null +++ b/.scripts/trace-conformance/normalize.py @@ -0,0 +1,551 @@ +import json +import os +from contextlib import contextmanager +from dataclasses import asdict +from functools import wraps +from pathlib import Path +from typing import Any, Iterable, Mapping + +from contract import SpanManifest, TraceManifest + + +def _get(value: Any, *names: str, default: Any = None) -> Any: + for name in names: + if isinstance(value, Mapping) and name in value: + return value[name] + if hasattr(value, name): + return getattr(value, name) + return default + + +def _decode(value: Any) -> Any: + if isinstance(value, str): + stripped = value.strip() + if stripped.startswith(("{", "[")): + try: + return json.loads(stripped) + except (TypeError, ValueError): + return value + return value + + +def _attributes(span: Any) -> dict[str, Any]: + value = _get(span, "attributes", default={}) + value = _decode(value) + return dict(value) if isinstance(value, Mapping) else {} + + +def _attribute(attributes: Mapping[str, Any], *names: str, default=None): + for name in names: + if name in attributes: + return attributes[name] + return default + + +def _status(span: Any, attributes: Mapping[str, Any]) -> str | None: + value = _get(span, "status", default=None) + if isinstance(value, Mapping): + value = _get(value, "status_code", "statusCode", "code", default=None) + elif value is not None and not isinstance(value, (str, int)): + value = _get(value, "status_code", "statusCode", "code", default=value) + if value is None: + value = _attribute( + attributes, "mlflow.spanStatus", "otel.status_code", "status", default=None + ) + if isinstance(value, int): + return {0: None, 1: "OK", 2: "ERROR"}.get(value) + if value is None: + return None + normalized = str(value).upper() + if normalized.endswith(".OK"): + return "OK" + if normalized.endswith(".ERROR"): + return "ERROR" + return { + "STATUS_CODE_OK": "OK", + "STATUS_CODE_ERROR": "ERROR", + "UNSET": None, + }.get(normalized, normalized) + + +def _latency_ms(span: Any, attributes: Mapping[str, Any]) -> float | None: + direct = _get(span, "latency_ms", "latencyMs", "duration_ms", "durationMs") + if direct is not None and not isinstance(direct, Mapping): + return float(direct) + attribute_value = _attribute( + attributes, + "mlflow.spanLatencyMs", + "mlflow.spanLatency", + "latency_ms", + "duration_ms", + ) + if attribute_value is not None: + return float(attribute_value) + duration = _get(span, "duration") + if isinstance(duration, (list, tuple)) and len(duration) == 2: + return float(duration[0]) * 1000 + float(duration[1]) / 1_000_000 + start = _get(span, "start_time_unix_nano", "startTimeUnixNano", "start_time_ns") + end = _get(span, "end_time_unix_nano", "endTimeUnixNano", "end_time_ns") + if start is not None and end is not None: + return (float(end) - float(start)) / 1_000_000 + return None + + +def _span_context(span: Any) -> Any: + method = _get(span, "spanContext", "span_context", default=None) + return method() if callable(method) else method + + +def _ids(span: Any) -> tuple[str | None, str | None, str | None]: + context = _span_context(span) + trace_id = _get(span, "trace_id", "traceId") or _get(context, "trace_id", "traceId") + span_id = _get(span, "span_id", "spanId") or _get(context, "span_id", "spanId") + parent_context = _get(span, "parent_span_context", "parentSpanContext") + parent_id = _get(span, "parent_id", "parent_span_id", "parentSpanId") or _get( + parent_context, "span_id", "spanId" + ) + return trace_id, span_id, parent_id + + +def _links(span: Any) -> list[dict[str, str]]: + normalized = [] + for link in _get(span, "links", default=[]) or []: + context = _get(link, "context", default=link) + trace_id = _get(context, "trace_id", "traceId") + span_id = _get(context, "span_id", "spanId") + if trace_id is not None or span_id is not None: + normalized.append({"trace_id": trace_id, "span_id": span_id}) + return normalized + + +def _usage(attributes: Mapping[str, Any], *, root: bool) -> dict[str, Any]: + names = ( + ( + "mlflow.trace.tokenUsage", + "mlflow.trace.token_usage", + "appkit.usage", + "token_usage", + ) + if root + else ( + "mlflow.chat.tokenUsage", + "mlflow.chat.token_usage", + "appkit.usage", + "gen_ai.usage", + "usage", + ) + ) + value = _attribute(attributes, *names, default={}) + value = _decode(value) + if not isinstance(value, Mapping): + return {} + aliases = { + "input_tokens": ( + "input_tokens", + "prompt_tokens", + "inputTokens", + "promptTokens", + ), + "output_tokens": ( + "output_tokens", + "completion_tokens", + "outputTokens", + "completionTokens", + ), + "total_tokens": ("total_tokens", "totalTokens"), + } + result = {} + for canonical, candidates in aliases.items(): + result[canonical] = next( + (value[candidate] for candidate in candidates if candidate in value), None + ) + return {key: nested for key, nested in result.items() if nested is not None} + + +def _normalize_attributes(attributes: dict[str, Any]) -> dict[str, Any]: + result = dict(attributes) + streaming = _attribute(attributes, "streaming", "gen_ai.response.streaming") + if streaming is not None: + result["streaming"] = streaming + ttft = _attribute( + attributes, + "ttft_ms", + "appkit.ttft_ms", + "gen_ai.latency.time_to_first_token_ms", + "mlflow.chat.ttft_ms", + ) + if ttft is not None: + result["ttft_ms"] = ttft + stream_duration = _attribute( + attributes, + "stream_duration_ms", + "appkit.stream_duration_ms", + "gen_ai.latency.stream_ms", + "mlflow.chat.stream_duration_ms", + ) + if stream_duration is not None: + result["stream_duration_ms"] = stream_duration + identity_aliases = { + "app_id": ( + "app_id", + "app.id", + "appkit.app.name", + "mlflow.trace.app_id", + ), + "user_id": ("user_id", "user.id", "mlflow.trace.user", "mlflow.trace.user_id"), + "session_id": ( + "session_id", + "session.id", + "mlflow.trace.session", + "mlflow.trace.session_id", + ), + } + for canonical, aliases in identity_aliases.items(): + value = _attribute(attributes, *aliases) + if value is not None: + result[canonical] = value + return result + + +def _normalize_span(span: Any) -> tuple[str | None, SpanManifest]: + attributes = _attributes(span) + trace_id, span_id, parent_span_id = _ids(span) + span_type = _get(span, "span_type", "spanType") or _attribute( + attributes, "mlflow.spanType", "span_type" + ) + span_type = _get(span_type, "value", default=span_type) + normalized_attributes = _normalize_attributes(attributes) + root = span_type == "AGENT" and parent_span_id is None + inputs = _get(span, "inputs", default=None) + if inputs is None: + inputs = _attribute(attributes, "mlflow.spanInputs", "inputs") + outputs = _get(span, "outputs", default=None) + if outputs is None: + outputs = _attribute(attributes, "mlflow.spanOutputs", "outputs") + appkit_usage = _decode(_attribute(attributes, "appkit.usage", default={})) + if not isinstance(appkit_usage, Mapping): + appkit_usage = {} + cost = _attribute(attributes, "mlflow.llm.cost", "appkit.cost_usd", "cost_usd") + if cost is None: + cost = _get(appkit_usage, "costUsd", "cost_usd") + cost_available = _attribute( + attributes, + "appkit.cost.available", + "appkit.cost_available", + "mlflow.cost.available", + "cost_available", + ) + if cost_available is None: + cost_available = _get(appkit_usage, "costAvailable", "cost_available") + if cost_available is None: + cost_available = cost is not None + if cost_available is False: + # Provider/autolog integrations sometimes emit a default zero cost even + # when the production span explicitly records that pricing is unknown. + # The explicit availability signal is authoritative; retaining that + # synthetic zero would turn "unknown" into a false priced result. + cost = None + manifest = SpanManifest( + name=_get(span, "name"), + span_type=span_type, + span_id=span_id, + parent_span_id=parent_span_id, + inputs=_decode(inputs), + outputs=_decode(outputs), + status=_status(span, attributes), + latency_ms=_latency_ms(span, attributes), + model=_attribute( + attributes, + "mlflow.chat.model", + "gen_ai.request.model", + "gen_ai.response.model", + "appkit.model", + "model", + ), + provider=_attribute( + attributes, + "mlflow.chat.provider", + "gen_ai.provider.name", + "gen_ai.system", + "appkit.provider", + "provider", + ), + usage=_usage(attributes, root=root), + cost_usd=_cost_value(cost), + cost_available=cost_available, + links=_links(span), + attributes=normalized_attributes, + ) + return trace_id, manifest + + +def _cost_value(value: Any) -> float | None: + value = _decode(value) + if value is None: + return None + if isinstance(value, Mapping): + value = _get( + value, + "cost_usd", + "costUsd", + "total_cost", + "totalCost", + "value", + "amount", + ) + if value is None: + return None + return float(value) + + +def _normalize( + template: str, trace_id: str | None, spans: Iterable[Any] +) -> TraceManifest: + normalized = [] + observed_trace_ids = set() + for raw_span in spans: + span_trace_id, span = _normalize_span(raw_span) + normalized.append(span) + if span_trace_id: + observed_trace_ids.add(span_trace_id) + if trace_id: + observed_trace_ids.add(trace_id) + if len(observed_trace_ids) != 1: + raise AssertionError( + f"template={template} span= field=trace_id: mixed or missing trace IDs " + f"{sorted(observed_trace_ids)!r}" + ) + return TraceManifest( + template=template, trace_id=next(iter(observed_trace_ids)), spans=normalized + ) + + +def normalize_python_mlflow_trace(template: str, trace: Any) -> TraceManifest: + info = _get(trace, "info", default={}) + data = _get(trace, "data", default={}) + trace_id = _get(info, "trace_id", "traceId") + manifest = _normalize(template, trace_id, _get(data, "spans", default=[])) + metadata = _get(info, "trace_metadata", "traceMetadata", default={}) + if isinstance(metadata, Mapping): + roots = [span for span in manifest.spans if span.parent_span_id is None] + if len(roots) == 1: + roots[0].attributes.update(_normalize_attributes(dict(metadata))) + return manifest + + +def normalize_mlflow_core_trace(template: str, trace: Any) -> TraceManifest: + info = _get(trace, "info", default={}) + data = _get(trace, "data", default={}) + trace_id = _get(info, "trace_id", "traceId") or _get(trace, "trace_id", "traceId") + return _normalize(template, trace_id, _get(data, "spans", default=[])) + + +def normalize_appkit_otel_trace(template: str, spans: Iterable[Any]) -> TraceManifest: + return _normalize(template, None, spans) + + +def normalize_uc_rows(template: str, rows: Iterable[Any]) -> TraceManifest: + rows = list(rows) + trace_id = _get(rows[0], "trace_id", "traceId") if rows else None + return _normalize(template, trace_id, rows) + + +def write_trace_manifest(path: Path | str, trace: TraceManifest) -> None: + destination = Path(path) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(json.dumps(asdict(trace), indent=2, sort_keys=True) + "\n") + + +def load_trace_manifest(path: Path | str) -> TraceManifest: + value = json.loads(Path(path).read_text()) + return TraceManifest( + template=value["template"], + trace_id=value["trace_id"], + spans=[SpanManifest(**span) for span in value["spans"]], + ) + + +_TRACE_CAPTURE_ERRORS: list[str] = [] +_PROCESS_TRACE_IDS: list[str] = [] +_PROCESS_TRACE_LOCATIONS: dict[str, str] = {} + + +def _install_trace_id_recorder(span_api, tracking_api=None) -> None: + tracking_api = tracking_api or span_api + + def record_trace_id(trace_id) -> None: + if trace_id and trace_id not in _PROCESS_TRACE_IDS: + _PROCESS_TRACE_IDS.append(trace_id) + _PROCESS_TRACE_LOCATIONS[trace_id] = str(tracking_api.get_tracking_uri()) + + def record(span) -> None: + record_trace_id(getattr(span, "trace_id", None)) + + original_start_span = getattr(span_api, "start_span", None) + if original_start_span is not None and not getattr( + original_start_span, "_trace_conformance_recorder", False + ): + + @wraps(original_start_span) + @contextmanager + def recording_start_span(*args, **kwargs): + with original_start_span(*args, **kwargs) as span: + record(span) + yield span + + recording_start_span._trace_conformance_recorder = True + span_api.start_span = recording_start_span + + original_start_span_no_context = getattr(span_api, "start_span_no_context", None) + if original_start_span_no_context is not None and not getattr( + original_start_span_no_context, "_trace_conformance_recorder", False + ): + + @wraps(original_start_span_no_context) + def recording_start_span_no_context(*args, **kwargs): + span = original_start_span_no_context(*args, **kwargs) + record(span) + return span + + recording_start_span_no_context._trace_conformance_recorder = True + span_api.start_span_no_context = recording_start_span_no_context + + original_get_trace = getattr(span_api, "get_trace", None) + if original_get_trace is not None and not getattr( + original_get_trace, "_trace_conformance_recorder", False + ): + + @wraps(original_get_trace) + def recording_get_trace(*args, **kwargs): + trace = original_get_trace(*args, **kwargs) + info = _get(trace, "info", default={}) + record_trace_id( + _get(info, "trace_id", "traceId") or _get(trace, "trace_id", "traceId") + ) + return trace + + recording_get_trace._trace_conformance_recorder = True + span_api.get_trace = recording_get_trace + + original_get_last_active_trace_id = getattr( + span_api, "get_last_active_trace_id", None + ) + if original_get_last_active_trace_id is not None and not getattr( + original_get_last_active_trace_id, "_trace_conformance_recorder", False + ): + + @wraps(original_get_last_active_trace_id) + def recording_get_last_active_trace_id(*args, **kwargs): + trace_id = original_get_last_active_trace_id(*args, **kwargs) + record_trace_id(trace_id) + return trace_id + + recording_get_last_active_trace_id._trace_conformance_recorder = True + span_api.get_last_active_trace_id = recording_get_last_active_trace_id + + +def _capture_active_pytest_trace() -> None: + """Write the first conformant success and injected-failure traces. + + This hook is activated only by ``run_local_trace_test`` in a child pytest + process. The template's real deterministic test owns trace creation; this + module only reads the resulting local MLflow store and normalizes it. + """ + destination = os.environ.get("TRACE_CONFORMANCE_MANIFEST") + failure_destination = os.environ.get("TRACE_CONFORMANCE_FAILURE_MANIFEST") + template = os.environ.get("TRACE_CONFORMANCE_TEMPLATE") + if not destination or not template: + return + success_missing = not Path(destination).exists() + failure_missing = ( + bool(failure_destination) and not Path(failure_destination).exists() + ) + if not success_missing and not failure_missing: + return + try: + import mlflow + + from contract import assert_trace_contract + + tracking_uri = str(mlflow.get_tracking_uri()) + if not tracking_uri.startswith("databricks"): + trace_id = mlflow.get_last_active_trace_id() + if trace_id and trace_id not in _PROCESS_TRACE_IDS: + _PROCESS_TRACE_IDS.append(trace_id) + _PROCESS_TRACE_LOCATIONS[trace_id] = tracking_uri + candidates = [] + for trace_id in reversed(_PROCESS_TRACE_IDS): + from mlflow.tracing.trace_manager import InMemoryTraceManager + + with InMemoryTraceManager.get_instance().get_trace(trace_id) as pending: + if pending is not None: + candidates.append(pending.to_mlflow_trace()) + continue + current_tracking_uri = str(mlflow.get_tracking_uri()) + try: + mlflow.set_tracking_uri(_PROCESS_TRACE_LOCATIONS[trace_id]) + trace = mlflow.get_trace(trace_id, silent=True) + finally: + mlflow.set_tracking_uri(current_tracking_uri) + if trace is not None: + candidates.append(trace) + for trace in candidates: + manifest = normalize_python_mlflow_trace(template, trace) + try: + assert_trace_contract(manifest) + except AssertionError as error: + _TRACE_CAPTURE_ERRORS.append(str(error)) + continue + is_failure = any(span.status == "ERROR" for span in manifest.spans) + if is_failure and failure_missing and failure_destination: + write_trace_manifest(failure_destination, manifest) + failure_missing = False + elif not is_failure and success_missing: + write_trace_manifest(destination, manifest) + success_missing = False + if not success_missing and not failure_missing: + return + except Exception as error: + _TRACE_CAPTURE_ERRORS.append(f"capture error: {type(error).__name__}: {error}") + # Individual tests may not have created a trace yet. The session-finish + # hook below fails closed if no later test produces a valid manifest. + return + + +if os.environ.get("TRACE_CONFORMANCE_MANIFEST"): + import mlflow + import pytest + from mlflow.tracing import fluent + + _install_trace_id_recorder(mlflow) + _install_trace_id_recorder(fluent, tracking_api=mlflow) + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_call(item): + outcome = yield + if outcome.excinfo is None: + _capture_active_pytest_trace() + + def pytest_sessionfinish(session, exitstatus): + destination = Path(os.environ["TRACE_CONFORMANCE_MANIFEST"]) + failure_value = os.environ.get("TRACE_CONFORMANCE_FAILURE_MANIFEST") + failure_destination = Path(failure_value) if failure_value else None + missing = [] + if not destination.exists(): + missing.append("success") + if failure_destination is not None and not failure_destination.exists(): + missing.append("injected-failure") + if exitstatus == 0 and missing: + session.exitstatus = pytest.ExitCode.TESTS_FAILED + session.config.pluginmanager.get_plugin("terminalreporter").write_line( + "trace conformance " + + " and ".join(missing) + + " manifest was not produced by any deterministic test" + + f"; recorded trace IDs: {len(_PROCESS_TRACE_IDS)}" + + ( + "; failures: " + " | ".join(dict.fromkeys(_TRACE_CAPTURE_ERRORS)) + if _TRACE_CAPTURE_ERRORS + else "" + ), + red=True, + ) diff --git a/.scripts/trace-conformance/test_contract.py b/.scripts/trace-conformance/test_contract.py new file mode 100644 index 00000000..8f537480 --- /dev/null +++ b/.scripts/trace-conformance/test_contract.py @@ -0,0 +1,709 @@ +import copy +import json +import sys +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace +from types import ModuleType + +import pytest + +sys.path.insert(0, str(Path(__file__).parent)) + +from contract import SpanManifest, TraceManifest, assert_trace_contract +import normalize +from normalize import ( + normalize_appkit_otel_trace, + normalize_mlflow_core_trace, + normalize_python_mlflow_trace, + normalize_uc_rows, +) + + +TRACE_ID = "0123456789abcdef0123456789abcdef" +REMOTE_TRACE_ID = "fedcba9876543210fedcba9876543210" +RETRIEVED_TRACE_ID = "00112233445566778899aabbccddeeff" +ACTIVE_TRACE_ID = "ffeeddccbbaa99887766554433221100" + + +def _span( + name, + span_type, + span_id, + parent_span_id, + *, + inputs=None, + outputs=None, + status="OK", + latency_ms=12.5, + model=None, + provider=None, + usage=None, + cost_usd=None, + cost_available=False, + links=None, + attributes=None, +): + return SpanManifest( + name=name, + span_type=span_type, + span_id=span_id, + parent_span_id=parent_span_id, + inputs={"value": "input"} if inputs is None else inputs, + outputs={"value": "output"} if outputs is None else outputs, + status=status, + latency_ms=latency_ms, + model=model, + provider=provider, + usage={} if usage is None else usage, + cost_usd=cost_usd, + cost_available=cost_available, + links=[] if links is None else links, + attributes={} if attributes is None else attributes, + ) + + +def _manifest(*children, root_usage=None, root_cost=None, cost_available=False): + usage = root_usage or { + "input_tokens": 7, + "output_tokens": 3, + "total_tokens": 10, + } + root = _span( + "request", + "AGENT", + "root", + None, + usage=usage, + cost_usd=root_cost, + cost_available=cost_available, + attributes={ + "app_id": "test-app", + "user_id": "user-123", + "session_id": "session-456", + }, + ) + return TraceManifest( + template="fixture-template", trace_id=TRACE_ID, spans=[root, *children] + ) + + +def _model( + *, + span_id="model", + parent_span_id="root", + usage=None, + cost_usd=None, + cost_available=False, + attributes=None, +): + return _span( + "model call", + "CHAT_MODEL", + span_id, + parent_span_id, + model="databricks-meta-llama-3-3-70b-instruct", + provider="databricks", + usage=usage or {"input_tokens": 7, "output_tokens": 3, "total_tokens": 10}, + cost_usd=cost_usd, + cost_available=cost_available, + attributes=attributes, + ) + + +def _valid_workloads(): + simple = _manifest(_model()) + + tool = _span( + "get weather", + "TOOL", + "tool", + "root", + inputs={"city": "San Francisco"}, + outputs={"temperature": 65}, + ) + tool_using = _manifest(_model(span_id="plan"), tool) + + retrieval = _span( + "vector search", + "RETRIEVER", + "retriever", + "root", + inputs={"query": "refund policy"}, + outputs={"documents": ["Returns are accepted for 30 days."]}, + ) + rag = _manifest(retrieval, _model(parent_span_id="retriever")) + + remote = _span( + "remote agent", + "TOOL", + "remote", + "root", + inputs={"request": "delegate"}, + outputs={"response": "complete"}, + links=[{"trace_id": REMOTE_TRACE_ID, "span_id": "0123456789abcdef"}], + attributes={ + "remote_trace_id": REMOTE_TRACE_ID, + "remote_span_id": "0123456789abcdef", + "remote_lifecycle_complete": True, + }, + ) + delegated = _manifest(_model(), remote) + return [simple, tool_using, rag, delegated] + + +@pytest.mark.parametrize( + "manifest", + _valid_workloads(), + ids=["simple", "tool-using", "retrieval-generation", "remote-agent"], +) +def test_contract_accepts_complete_workload_shapes(manifest): + assert_trace_contract(manifest) + + +def test_contract_accepts_explicitly_redacted_bearer_error_text(): + model = _model() + model.status = "ERROR" + model.outputs = { + "partial_output": {"available": False, "reason": "no output produced"}, + "error": "authorization Bearer [REDACTED]", + } + + assert_trace_contract(_manifest(model)) + + +@pytest.mark.parametrize( + ("mutation", "expected_span", "expected_field"), + [ + ("root-only", "request", "semantic child"), + ("missing-model-output", "model call", "outputs"), + ("missing-model-usage", "model call", "usage"), + ("false-zero-cost", "model call", "cost_usd"), + ("orphan-remote", "get weather", "links"), + ("incomplete-tool", "get weather", "outputs"), + ("missing-identity", "request", "user_id"), + ("duplicate-roots", "second request", "AGENT root"), + ("unfinalized", "model call", "status"), + ("failure-without-partial-output", "model call", "outputs"), + ("wrong-aggregate-usage", "request", "usage.total_tokens"), + ("credential-leak", "get weather", "credentials"), + ("noncanonical-name", " model\ncall ", "name"), + ], +) +def test_contract_rejects_incomplete_or_untruthful_traces( + mutation, expected_span, expected_field +): + manifest = _manifest( + _model(), + _span( + "get weather", + "TOOL", + "tool", + "root", + inputs={"city": "San Francisco"}, + outputs={"temperature": 65}, + ), + ) + if mutation == "root-only": + manifest.spans = manifest.spans[:1] + elif mutation == "missing-model-output": + manifest.spans[1].outputs = None + elif mutation == "missing-model-usage": + manifest.spans[1].usage = {} + elif mutation == "false-zero-cost": + manifest.spans[1].cost_available = False + manifest.spans[1].cost_usd = 0.0 + elif mutation == "orphan-remote": + manifest.spans[2].attributes = { + "remote_trace_id": REMOTE_TRACE_ID, + "remote_span_id": "0123456789abcdef", + "remote_lifecycle_complete": True, + } + elif mutation == "incomplete-tool": + manifest.spans[2].outputs = None + elif mutation == "missing-identity": + del manifest.spans[0].attributes["user_id"] + elif mutation == "duplicate-roots": + manifest.spans.append(_span("second request", "AGENT", "root-2", None)) + elif mutation == "unfinalized": + manifest.spans[1].status = None + elif mutation == "failure-without-partial-output": + manifest.spans[1].status = "ERROR" + manifest.spans[1].outputs = {"error": "provider unavailable"} + elif mutation == "wrong-aggregate-usage": + manifest.spans[0].usage["total_tokens"] = 9 + elif mutation == "credential-leak": + manifest.spans[2].inputs = {"Authorization": "Bearer provider-secret"} + elif mutation == "noncanonical-name": + manifest.spans[1].name = " model\ncall " + + with pytest.raises(AssertionError) as error: + assert_trace_contract(manifest) + + message = str(error.value) + assert "fixture-template" in message + assert expected_span in message + assert expected_field in message + + +@pytest.mark.parametrize("missing_field", ["ttft_ms", "stream_duration_ms"]) +def test_contract_rejects_streaming_model_without_complete_timing(missing_field): + model = _model( + attributes={"streaming": True, "ttft_ms": 4.5, "stream_duration_ms": 18.0} + ) + del model.attributes[missing_field] + + with pytest.raises(AssertionError) as error: + assert_trace_contract(_manifest(model)) + + assert "fixture-template" in str(error.value) + assert "model call" in str(error.value) + assert missing_field in str(error.value) + + +def test_contract_accepts_truthful_available_cost_and_aggregates_it_once(): + first = _model( + span_id="model-1", + usage={"input_tokens": 4, "output_tokens": 1, "total_tokens": 5}, + cost_usd=0.01, + cost_available=True, + ) + second = _model( + span_id="model-2", + usage={"input_tokens": 3, "output_tokens": 2, "total_tokens": 5}, + cost_usd=0.02, + cost_available=True, + ) + manifest = _manifest( + first, + second, + root_usage={"input_tokens": 7, "output_tokens": 3, "total_tokens": 10}, + root_cost=0.03, + cost_available=True, + ) + + assert_trace_contract(manifest) + + +def test_contract_accepts_failed_span_with_bounded_partial_output(): + model = _model() + model.status = "ERROR" + model.outputs = { + "partial_output": "The partial answer", + "error": "provider unavailable", + } + manifest = _manifest(model) + manifest.spans[0].status = "ERROR" + manifest.spans[0].outputs = { + "partial_output": "The partial answer", + "error": "provider unavailable", + } + + assert_trace_contract(manifest) + + +def test_contract_rejects_parent_cycle_disconnected_from_the_root(): + model = _model(parent_span_id="tool") + tool = _span( + "get weather", + "TOOL", + "tool", + "model", + inputs={"city": "San Francisco"}, + outputs={"temperature": 65}, + ) + + with pytest.raises(AssertionError) as error: + assert_trace_contract(_manifest(model, tool)) + + assert "fixture-template" in str(error.value) + assert "parent_span_id" in str(error.value) + assert "cycle" in str(error.value) + + +def _complete_attributes(span_type, *, root=False, streaming=False): + attributes = { + "mlflow.spanType": span_type, + "mlflow.spanInputs": {"prompt": "hello"}, + "mlflow.spanOutputs": {"text": "hello back"}, + "mlflow.spanStatus": "OK", + "mlflow.spanLatencyMs": 10.0, + "appkit.cost.available": False, + } + if root: + attributes.update( + { + "mlflow.trace.tokenUsage": { + "input_tokens": 7, + "output_tokens": 3, + "total_tokens": 10, + }, + "app.id": "test-app", + "user.id": "user-123", + "session.id": "session-456", + } + ) + else: + attributes.update( + { + "gen_ai.request.model": "test-model", + "gen_ai.provider.name": "databricks", + "mlflow.chat.tokenUsage": { + "input_tokens": 7, + "output_tokens": 3, + "total_tokens": 10, + }, + } + ) + if streaming: + attributes.update( + { + "streaming": True, + "gen_ai.latency.time_to_first_token_ms": 2.0, + "gen_ai.latency.stream_ms": 8.0, + } + ) + return attributes + + +def test_normalizes_python_mlflow_without_losing_ids_or_links(): + link = {"trace_id": REMOTE_TRACE_ID, "span_id": "0123456789abcdef"} + trace = SimpleNamespace( + info=SimpleNamespace(trace_id=TRACE_ID), + data=SimpleNamespace( + spans=[ + SimpleNamespace( + trace_id=TRACE_ID, + span_id="root", + parent_id=None, + name="request", + span_type="AGENT", + inputs={"prompt": "hello"}, + outputs={"text": "hello back"}, + status="OK", + latency_ms=15.0, + links=[], + attributes=_complete_attributes("AGENT", root=True), + ), + SimpleNamespace( + trace_id=TRACE_ID, + span_id="model", + parent_id="root", + name="model call", + span_type="CHAT_MODEL", + inputs={"prompt": "hello"}, + outputs={"text": "hello back"}, + status="OK", + latency_ms=10.0, + links=[link], + attributes=_complete_attributes("CHAT_MODEL"), + ), + ] + ), + ) + + manifest = normalize_python_mlflow_trace("python-template", trace) + + assert manifest.trace_id == TRACE_ID + assert manifest.spans[1].span_id == "model" + assert manifest.spans[1].parent_span_id == "root" + assert manifest.spans[1].links == [link] + assert_trace_contract(manifest) + + +def test_normalizes_mlflow_core_camel_case_shape(): + link = {"traceId": REMOTE_TRACE_ID, "spanId": "0123456789abcdef"} + raw = { + "info": {"traceId": TRACE_ID}, + "data": { + "spans": [ + { + "traceId": TRACE_ID, + "spanId": "root", + "parentSpanId": None, + "name": "request", + "spanType": "AGENT", + "inputs": {"prompt": "hello"}, + "outputs": {"text": "hello back"}, + "status": "OK", + "latencyMs": 15.0, + "links": [], + "attributes": _complete_attributes("AGENT", root=True), + }, + { + "traceId": TRACE_ID, + "spanId": "model", + "parentSpanId": "root", + "name": "model call", + "spanType": "CHAT_MODEL", + "inputs": {"prompt": "hello"}, + "outputs": {"text": "hello back"}, + "status": {"statusCode": "OK"}, + "latencyMs": 10.0, + "links": [link], + "attributes": _complete_attributes("CHAT_MODEL"), + }, + ] + }, + } + + manifest = normalize_mlflow_core_trace("typescript-template", raw) + + assert manifest.trace_id == TRACE_ID + assert manifest.spans[1].links == [ + {"trace_id": REMOTE_TRACE_ID, "span_id": "0123456789abcdef"} + ] + assert_trace_contract(manifest) + + +class _OtelSpan: + def __init__(self, *, root): + self.name = "request" if root else "model call" + self.attributes = _complete_attributes( + "AGENT" if root else "CHAT_MODEL", root=root, streaming=not root + ) + self.status = SimpleNamespace(code=1) + self.duration = [0, 15_000_000 if root else 10_000_000] + self.parentSpanContext = None if root else SimpleNamespace(spanId="root") + self.links = ( + [] + if root + else [ + SimpleNamespace( + context=SimpleNamespace( + traceId=REMOTE_TRACE_ID, spanId="0123456789abcdef" + ) + ) + ] + ) + self._span_context = SimpleNamespace( + traceId=TRACE_ID, spanId="root" if root else "model" + ) + + def spanContext(self): + return self._span_context + + +def test_pytest_capture_records_trace_ids_at_their_creation_location(): + fake_mlflow = ModuleType("mlflow") + + @contextmanager + def start_span(*_args, **_kwargs): + yield SimpleNamespace(trace_id=TRACE_ID) + + fake_mlflow.start_span = start_span + fake_mlflow.start_span_no_context = lambda *_args, **_kwargs: SimpleNamespace( + trace_id=REMOTE_TRACE_ID + ) + fake_mlflow.get_trace = lambda *_args, **_kwargs: SimpleNamespace( + info=SimpleNamespace(trace_id=RETRIEVED_TRACE_ID) + ) + fake_mlflow.get_last_active_trace_id = lambda: ACTIVE_TRACE_ID + fake_mlflow.get_tracking_uri = lambda: "sqlite:///created.db" + normalize._PROCESS_TRACE_IDS.clear() + normalize._PROCESS_TRACE_LOCATIONS.clear() + + normalize._install_trace_id_recorder(fake_mlflow) + with fake_mlflow.start_span("request") as span: + assert span.trace_id == TRACE_ID + assert fake_mlflow.start_span_no_context("detached").trace_id == REMOTE_TRACE_ID + assert fake_mlflow.get_trace(RETRIEVED_TRACE_ID).info.trace_id == RETRIEVED_TRACE_ID + assert fake_mlflow.get_last_active_trace_id() == ACTIVE_TRACE_ID + + assert normalize._PROCESS_TRACE_IDS == [ + TRACE_ID, + REMOTE_TRACE_ID, + RETRIEVED_TRACE_ID, + ACTIVE_TRACE_ID, + ] + assert normalize._PROCESS_TRACE_LOCATIONS == { + TRACE_ID: "sqlite:///created.db", + REMOTE_TRACE_ID: "sqlite:///created.db", + RETRIEVED_TRACE_ID: "sqlite:///created.db", + ACTIVE_TRACE_ID: "sqlite:///created.db", + } + + +def test_pytest_capture_retrieves_recorded_local_trace_after_current_uri_is_restored( + monkeypatch, + tmp_path, +): + trace = SimpleNamespace( + info=SimpleNamespace(trace_id=TRACE_ID), + data=SimpleNamespace(spans=[_OtelSpan(root=True), _OtelSpan(root=False)]), + ) + calls = [] + current_tracking_uri = ["databricks"] + created_tracking_uri = str(tmp_path / "created") + fake_mlflow = ModuleType("mlflow") + fake_mlflow.get_tracking_uri = lambda: current_tracking_uri[0] + fake_mlflow.set_tracking_uri = lambda uri: current_tracking_uri.__setitem__(0, uri) + fake_mlflow.get_last_active_trace_id = lambda: TRACE_ID + + def get_trace(trace_id, **kwargs): + assert current_tracking_uri[0] == created_tracking_uri + calls.append((trace_id, kwargs)) + return trace + + fake_mlflow.get_trace = get_trace + fake_mlflow.search_experiments = lambda: (_ for _ in ()).throw( + AssertionError("capture must not scan experiments") + ) + + @contextmanager + def no_pending_trace(_trace_id): + yield None + + manager = SimpleNamespace(get_trace=no_pending_trace) + trace_manager = ModuleType("mlflow.tracing.trace_manager") + trace_manager.InMemoryTraceManager = SimpleNamespace(get_instance=lambda: manager) + tracing = ModuleType("mlflow.tracing") + monkeypatch.setitem(sys.modules, "mlflow", fake_mlflow) + monkeypatch.setitem(sys.modules, "mlflow.tracing", tracing) + monkeypatch.setitem(sys.modules, "mlflow.tracing.trace_manager", trace_manager) + destination = tmp_path / "manifest.json" + monkeypatch.setenv("TRACE_CONFORMANCE_MANIFEST", str(destination)) + monkeypatch.setenv("TRACE_CONFORMANCE_TEMPLATE", "current-template") + normalize._TRACE_CAPTURE_ERRORS.clear() + normalize._PROCESS_TRACE_IDS.clear() + normalize._PROCESS_TRACE_LOCATIONS.clear() + normalize._PROCESS_TRACE_IDS.append(TRACE_ID) + normalize._PROCESS_TRACE_LOCATIONS[TRACE_ID] = created_tracking_uri + + normalize._capture_active_pytest_trace() + + captured = normalize.load_trace_manifest(destination) + assert captured.trace_id == TRACE_ID + assert calls == [(TRACE_ID, {"silent": True})] + assert normalize._TRACE_CAPTURE_ERRORS == [] + assert current_tracking_uri[0] == "databricks" + + +def test_normalizes_appkit_otel_shape_and_stream_timing(): + manifest = normalize_appkit_otel_trace( + "appkit-agents", [_OtelSpan(root=True), _OtelSpan(root=False)] + ) + + assert manifest.trace_id == TRACE_ID + assert manifest.spans[1].parent_span_id == "root" + assert manifest.spans[1].attributes["ttft_ms"] == 2.0 + assert manifest.spans[1].attributes["stream_duration_ms"] == 8.0 + assert manifest.spans[1].links == [ + {"trace_id": REMOTE_TRACE_ID, "span_id": "0123456789abcdef"} + ] + assert_trace_contract(manifest) + + +def test_normalizes_production_appkit_identity_and_timing_aliases(): + root_attributes = _complete_attributes("AGENT", root=True) + for key in ("app.id", "user.id", "session.id"): + del root_attributes[key] + root_attributes.update( + { + "appkit.app.name": "test-app", + "mlflow.trace.user": "user-123", + "mlflow.trace.session": "session-456", + } + ) + model_attributes = _complete_attributes("CHAT_MODEL") + model_attributes.update( + { + "streaming": True, + "appkit.ttft_ms": 2.0, + "appkit.stream_duration_ms": 8.0, + } + ) + root = _OtelSpan(root=True) + root.attributes = root_attributes + model = _OtelSpan(root=False) + model.attributes = model_attributes + + manifest = normalize_appkit_otel_trace("appkit-agents", [root, model]) + + assert manifest.spans[0].attributes["app_id"] == "test-app" + assert manifest.spans[0].attributes["user_id"] == "user-123" + assert manifest.spans[0].attributes["session_id"] == "session-456" + assert manifest.spans[1].attributes["ttft_ms"] == 2.0 + assert manifest.spans[1].attributes["stream_duration_ms"] == 8.0 + assert_trace_contract(manifest) + + +def test_normalizes_persisted_uc_rows_and_decodes_attributes(): + rows = [ + { + "trace_id": TRACE_ID, + "span_id": "root", + "parent_span_id": None, + "name": "request", + "attributes": json.dumps(_complete_attributes("AGENT", root=True)), + }, + { + "trace_id": TRACE_ID, + "span_id": "model", + "parent_span_id": "root", + "name": "model call", + "attributes": _complete_attributes("CHAT_MODEL"), + }, + ] + + manifest = normalize_uc_rows("deployed-template", rows) + + assert manifest.trace_id == TRACE_ID + assert [span.span_id for span in manifest.spans] == ["root", "model"] + assert_trace_contract(manifest) + + +def test_explicit_unavailable_cost_discards_provider_default_zero(): + root = _OtelSpan(root=True) + model = _OtelSpan(root=False) + root.attributes["appkit.cost_available"] = False + root.attributes["mlflow.llm.cost"] = {"total_cost": 0.0} + model.attributes["appkit.cost_available"] = False + model.attributes["mlflow.llm.cost"] = {"total_cost": 0.0} + + manifest = normalize_appkit_otel_trace("unknown-cost", [root, model]) + + assert manifest.spans[0].cost_usd is None + assert manifest.spans[1].cost_usd is None + assert_trace_contract(manifest) + + +def test_normalizer_does_not_mutate_provider_payloads(): + raw = { + "info": {"traceId": TRACE_ID}, + "data": { + "spans": [ + { + "traceId": TRACE_ID, + "spanId": "root", + "parentSpanId": None, + "name": "request", + "spanType": "AGENT", + "inputs": {"prompt": "hello"}, + "outputs": {"text": "hello back"}, + "status": "OK", + "latencyMs": 15.0, + "links": [], + "attributes": _complete_attributes("AGENT", root=True), + }, + { + "traceId": TRACE_ID, + "spanId": "model", + "parentSpanId": "root", + "name": "model call", + "spanType": "CHAT_MODEL", + "inputs": {"prompt": "hello"}, + "outputs": {"text": "hello back"}, + "status": "OK", + "latencyMs": 10.0, + "links": [], + "attributes": _complete_attributes("CHAT_MODEL"), + }, + ] + }, + } + before = copy.deepcopy(raw) + + normalize_mlflow_core_trace("typescript-template", raw) + + assert raw == before diff --git a/.scripts/trace-conformance/test_discovery.py b/.scripts/trace-conformance/test_discovery.py new file mode 100644 index 00000000..0758bee7 --- /dev/null +++ b/.scripts/trace-conformance/test_discovery.py @@ -0,0 +1,515 @@ +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent)) +sys.path.insert(0, str(Path(__file__).parents[1] / "agent-integration-tests")) + +from discovery import assert_template_policy, discover_agentic_templates +from template_config import build_trace_policy_templates + + +UC_ENV = """ +env: + - name: MLFLOW_EXPERIMENT_ID + - name: MLFLOW_TRACING_SQL_WAREHOUSE_ID + - name: MLFLOW_UC_CATALOG + - name: MLFLOW_UC_SCHEMA + - name: MLFLOW_UC_TABLE_PREFIX + - name: MLFLOW_OTEL_SPANS_TABLE +""" + + +def _write(path, content): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +def _write_policy_evidence(template): + _write(template / "app.yaml", UC_ENV) + _write( + template / "databricks.yml", + """ +resources: + experiments: + mlflow_experiment: {name: traced-agent} + sql_warehouses: + mlflow_tracing_warehouse: {id: warehouse} +""", + ) + _write( + template / "tests/test_trace_conformance.py", + """ +from trace_conformance import assert_trace_contract +from app import run_mocked_turn + +def test_mocked_turn_trace_contract(): + manifest = run_mocked_turn(deterministic=True) + assert_trace_contract(manifest) +""", + ) + _write( + template / "tests/deployed/test_trace_conformance.py", + """ +from trace_conformance import assert_trace_contract, normalize_uc_rows +from app import invoke_deployed_agent, get_trace, query_otel_spans + +def test_deployed_trace_persists_to_uc(): + response = invoke_deployed_agent(prompt="Use the time tool") + trace_id = response.trace_id + get_trace(trace_id) + rows = query_otel_spans(trace_id) + assert_trace_contract(normalize_uc_rows("fixture", rows)) +""", + ) + + +def _write_agent_trace_manifest(template): + _write( + template / "manifest.yaml", + """ +version: 1 +name: Trace-owning application +resource_specs: + - name: experiment + experiment_spec: + permission: CAN_EDIT +""", + ) + + +@pytest.fixture +def behavior_root(tmp_path): + signals = { + "agent-server": """ +from databricks.agents import AgentServer +server = AgentServer(agent=planner) +""", + "agent-constructor": """ +from agents import Agent +planner = Agent(name="planner", instructions="help") +""", + "agent-endpoint": """ +result = client.responses.create(model="agents/catalog/schema/planner", input="hello") +""", + "model-tool-loop": """ +while tool_calls: + response = model.invoke(messages) + messages.append(tool.execute(response.tool_calls[0])) +""", + "retrieval-generation": """ +documents = retriever.invoke(question) +return model.generate([question, documents]) +""", + } + for name, source in signals.items(): + _write(tmp_path / name / "src/app.py", source) + _write( + tmp_path / "non-agent" / "src/app.py", + "def health():\n return {'status': 'ok'}\n", + ) + return tmp_path + + +def test_discovers_every_agentic_signal_and_ignores_non_agent(behavior_root): + discovered = discover_agentic_templates(behavior_root) + + assert {template.name for template in discovered} == { + "agent-server", + "agent-constructor", + "agent-endpoint", + "model-tool-loop", + "retrieval-generation", + } + assert {signal for template in discovered for signal in template.signals} == { + "agent-server", + "agent-constructor", + "agent-endpoint", + "model-tool-loop", + "retrieval-generation", + } + + +def test_plain_model_invocation_is_not_misclassified_as_an_agent_endpoint(tmp_path): + _write( + tmp_path / "plain-model" / "src/moderate.ts", + "fetch(`${host}/serving-endpoints/text-model/invocations`, " + "{ body: JSON.stringify({ messages }) });\n", + ) + + assert discover_agentic_templates(tmp_path) == [] + + +def test_unrelated_behavior_tokens_in_different_files_do_not_form_a_tool_loop(tmp_path): + _write(tmp_path / "forecast" / "src/clock.py", "while current < deadline: pass\n") + _write(tmp_path / "forecast" / "src/model.py", "model = load_forecaster()\n") + _write(tmp_path / "forecast" / "src/write.py", "delta_table.execute()\n") + + assert discover_agentic_templates(tmp_path) == [] + + +def test_discovery_ignores_hidden_root_directories(tmp_path): + _write( + tmp_path / ".claude" / "src/app.py", + "from databricks.agents import AgentServer\nserver = AgentServer(agent=planner)\n", + ) + + assert discover_agentic_templates(tmp_path) == [] + + +def test_complete_behavior_discovered_template_passes_policy(behavior_root): + template = behavior_root / "agent-server" + _write_policy_evidence(template) + + discovered = discover_agentic_templates(behavior_root) + candidate = next(item for item in discovered if item.name == "agent-server") + + assert candidate.has_uc_resources is True + assert candidate.has_local_conformance is True + assert candidate.has_deployed_verification is True + assert_template_policy([candidate]) + + +def test_new_agentic_template_fails_until_all_three_proofs_exist(behavior_root): + new_template = behavior_root / "new-agent" + _write( + new_template / "src/index.ts", + "const supportAgent = new Agent({ name: 'support' });\n", + ) + + candidate = next( + item + for item in discover_agentic_templates(behavior_root) + if item.name == "new-agent" + ) + + with pytest.raises(AssertionError) as error: + assert_template_policy([candidate]) + + message = str(error.value) + assert "new-agent" in message + assert "UC resources" in message + assert "deterministic local conformance" in message + assert "deployed verification" in message + + +def test_policy_builder_admits_detected_candidate_without_name_or_command_filters( + tmp_path, +): + template = tmp_path / "support-surface" + _write( + template / "src/app.py", + "from databricks.agents import AgentServer\nserver = AgentServer(agent=planner)\n", + ) + _write_agent_trace_manifest(template) + + candidates = build_trace_policy_templates( + root=tmp_path, + deployed_template_names=set(), + ) + + assert [candidate.name for candidate in candidates] == ["support-surface"] + assert candidates[0].local_test_command is None + + +def test_policy_builder_selects_every_executable_behavior_without_using_evidence_as_a_filter( + tmp_path, +): + trace_owner = tmp_path / "support-surface" + _write( + trace_owner / "src/app.py", + "from databricks.agents import AgentServer\nserver = AgentServer(agent=planner)\n", + ) + _write_agent_trace_manifest(trace_owner) + + client_app = tmp_path / "agent-client" + _write( + client_app / "src/app.ts", + 'const result = client.responses.create(model="agents/c/s/a", input="hi");\n', + ) + + mcp_server = tmp_path / "traced-mcp-server" + _write( + mcp_server / "src/app.py", + "while tool_calls:\n" + " response = model.invoke(messages)\n" + " messages.append(tool.execute(response.tool_calls[0]))\n", + ) + + rag_app = tmp_path / "rag-app" + _write( + rag_app / "src/app.py", + "documents = retriever.invoke(question)\n" + "return model.generate([question, documents])\n", + ) + + candidates = build_trace_policy_templates(root=tmp_path) + + assert [candidate.name for candidate in candidates] == [ + "agent-client", + "rag-app", + "support-surface", + "traced-mcp-server", + ] + for candidate in candidates: + if candidate.name == "support-surface": + continue + with pytest.raises(AssertionError) as error: + assert_template_policy([candidate]) + message = str(error.value) + assert candidate.name in message + assert "UC resources" in message + assert "deterministic local conformance" in message + assert "deployed verification" in message + + +def test_policy_builder_does_not_share_deployed_proof_between_candidates(tmp_path): + for name in ("covered", "uncovered"): + _write( + tmp_path / name / "src/app.py", + "from databricks.agents import AgentServer\n" + "server = AgentServer(agent=planner)\n", + ) + + candidates = build_trace_policy_templates(root=tmp_path) + + assert { + candidate.name: candidate.has_deployed_verification for candidate in candidates + } == {"covered": False, "uncovered": False} + + +def test_agentic_support_console_owns_executable_trace_policy_proof(): + repository_root = Path(__file__).parents[2] + candidate = next( + template + for template in discover_agentic_templates(repository_root) + if template.name == "agentic-support-console" + ) + + assert candidate.local_test_command == ( + "uv", + "run", + "--offline", + "--frozen", + "--project", + ".scripts/agent-integration-tests", + "pytest", + "agentic-support-console/pipelines/support_agent/tests/test_tracing.py", + "-v", + ) + assert_template_policy([candidate]) + + +def test_generated_appkit_markers_without_executable_owner_proof_are_rejected(tmp_path): + template = tmp_path / "generated-agent" + _write(template / "app.yaml", UC_ENV) + _write( + template / "databricks.yml", + "resources:\n experiments:\n traced: {}\n sql_warehouses:\n trace: {}\n", + ) + _write( + template / "package.json", + '{"dependencies":{"@databricks/appkit":"0.60.0"}}', + ) + _write( + template / "appkit.plugins.json", + '{"plugins":{"agents":{"package":"@databricks/appkit",' + '"requiredByTemplate":true}}}', + ) + _write( + template / "server/server.ts", + "import { createApp } from '@databricks/appkit';\n" + "import { agents } from '@databricks/appkit/beta';\n" + "createApp({ plugins: [agents({ agents: {} })] });\n", + ) + _write( + template / "server/agents/helper.ts", + "export const helper = createAgent({ name: 'helper' });\n", + ) + + candidate = discover_agentic_templates(tmp_path)[0] + assert candidate.proof_owner is None + assert candidate.has_local_conformance is False + assert candidate.has_deployed_verification is False + with pytest.raises(AssertionError, match="deterministic local conformance"): + assert_template_policy([candidate]) + + +def test_generated_appkit_owner_is_discovered_by_behavior_or_explicit_config( + tmp_path, monkeypatch +): + monkeypatch.delenv("APPKIT_SOURCE_ROOT", raising=False) + templates_root = tmp_path / "app-templates" + template = templates_root / "generated-agent" + _write(template / "app.yaml", UC_ENV) + _write( + template / "databricks.yml", + "resources:\n experiments:\n traced: {}\n sql_warehouses:\n trace: {}\n", + ) + _write( + template / "package.json", + '{"dependencies":{"@databricks/appkit":"0.60.0"}}', + ) + _write( + template / "appkit.plugins.json", + '{"plugins":{"agents":{"package":"@databricks/appkit",' + '"requiredByTemplate":true}}}', + ) + _write( + template / "server/server.ts", + "import { createApp } from '@databricks/appkit';\n" + "import { agents } from '@databricks/appkit/beta';\n" + "createApp({ plugins: [agents({ agents: {} })] });\n", + ) + _write( + template / "server/agents/helper.ts", + "export const helper = createAgent({ name: 'helper' });\n", + ) + owner = tmp_path / "arbitrarily-named-appkit-checkout" + _write( + owner + / "packages/appkit/src/plugins/agents/tests/trace-conformance.integration.test.ts", + "test('generated trace conformance', () => {});\n", + ) + _write( + owner / "tools/generate-app-templates.ts", + 'const templates = [{ name: "generated-agent" }];\n', + ) + + candidate = discover_agentic_templates(templates_root)[0] + + assert candidate.proof_owner == str(owner.resolve()) + assert candidate.local_test_command == ( + "__appkit_generated_owner__", + str(owner.resolve()), + "generated-agent", + ) + assert_template_policy([candidate]) + + second_owner = tmp_path / "second-valid-appkit-checkout" + _write( + second_owner + / "packages/appkit/src/plugins/agents/tests/trace-conformance.integration.test.ts", + "test('generated trace conformance', () => {});\n", + ) + _write( + second_owner / "tools/generate-app-templates.ts", + 'const templates = [{ name: "generated-agent" }];\n', + ) + ambiguous = discover_agentic_templates(templates_root)[0] + assert ambiguous.proof_owner is None + + monkeypatch.setenv("APPKIT_SOURCE_ROOT", str(owner)) + configured = discover_agentic_templates(templates_root)[0] + assert configured.proof_owner == str(owner.resolve()) + + +def test_appkit_dependency_alone_cannot_delegate_trace_proof(tmp_path): + template = tmp_path / "ordinary-app" + _write_policy_evidence(template) + _write( + template / "package.json", + '{"dependencies":{"@databricks/appkit":"0.60.0"}}', + ) + _write( + template / "server/server.ts", + "const planner = createAgent({});\n", + ) + + candidate = discover_agentic_templates(tmp_path)[0] + assert candidate.proof_owner is None + (template / "tests/test_trace_conformance.py").unlink() + (template / "tests/deployed/test_trace_conformance.py").unlink() + candidate = discover_agentic_templates(tmp_path)[0] + with pytest.raises(AssertionError): + assert_template_policy([candidate]) + + +def test_python_local_command_uses_the_existing_lock_without_reresolution(tmp_path): + template = tmp_path / "python-agent" + _write( + template / "src/app.py", + "from databricks.agents import AgentServer\nserver = AgentServer(agent=planner)\n", + ) + _write(template / "pyproject.toml", "[project]\nname = 'python-agent'\n") + _write(template / "tests/test_tracing.py", "def test_trace(): pass\n") + + candidate = discover_agentic_templates(tmp_path)[0] + + assert candidate.local_test_command == ( + "uv", + "run", + "--offline", + "--frozen", + "--project", + "python-agent", + "pytest", + "python-agent/tests/test_tracing.py", + "-v", + ) + + +def test_real_sdk_span_hook_is_local_conformance_evidence(tmp_path): + template = tmp_path / "typescript-agent" + _write(template / "src/app.ts", "const planner = new Agent({ name: 'planner' });\n") + _write(template / "package.json", '{"scripts":{"test":"jest"}}') + _write( + template / "tests/framework/tracing.test.ts", + """ +const fetchGuard = jest.spyOn(globalThis, "fetch").mockImplementation(loopbackExporter); +mlflow.registerOnSpanEndHook((span) => spans.push(span)); +await runDeterministicTurn(); +fetchGuard.mockRestore(); +""", + ) + + candidate = discover_agentic_templates(tmp_path)[0] + + assert candidate.has_local_conformance is True + + +def test_deployment_preflight_smoke_is_deployed_verification_evidence(tmp_path): + template = tmp_path / "migration-surface" + _write(template / "src/app.py", "server = AgentServer(agent=planner)\n") + _write( + template / "tests/test_tracing.py", + """ +def test_deployment_preflight(): + verify_deployment_trace_resources(expected_unity_catalog_location) + trace_id = invoke_smoke_request() + verify_smoke_trace(trace_id) +""", + ) + + candidate = discover_agentic_templates(tmp_path)[0] + + assert candidate.has_deployed_verification is True + + +@pytest.mark.parametrize( + ("removed", "expected"), + [ + ("uc", "UC resources"), + ("local", "deterministic local conformance"), + ("deployed", "deployed verification"), + ], +) +def test_policy_reports_each_missing_behavioral_proof(tmp_path, removed, expected): + template = tmp_path / "agent-template" + _write(template / "src/app.py", "server = AgentServer(agent=planner)\n") + _write_policy_evidence(template) + if removed == "uc": + (template / "app.yaml").unlink() + (template / "databricks.yml").unlink() + elif removed == "local": + (template / "tests/test_trace_conformance.py").unlink() + else: + (template / "tests/deployed/test_trace_conformance.py").unlink() + + candidate = discover_agentic_templates(tmp_path)[0] + with pytest.raises(AssertionError) as error: + assert_template_policy([candidate]) + + assert "agent-template" in str(error.value) + assert expected in str(error.value) diff --git a/README.md b/README.md index 8edada06..a0031c22 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ A collection of templates for building full-stack Databricks Apps with [AppKit]( | Template | Description | Dependencies | |----------|-------------|--------------| -| `appkit-all-in-one` | Full-stack Node.js app with SQL analytics dashboards, file browser, Genie AI conversations, and Lakebase Autoscaling (Postgres) CRUD | SQL warehouse, Volume, Genie Space, Database | +| `appkit-all-in-one` | Full-stack Node.js app with traced agents, SQL analytics dashboards, file browser, Genie AI conversations, Lakebase Autoscaling (Postgres) CRUD, and Model Serving | MLflow experiment, SQL warehouse, Serving Endpoint, SQL warehouse, Volume, Genie Space, Database, Serving Endpoint | +| `appkit-agents` | Node.js agent app with MLflow Unity Catalog tracing and a composed planner/helper example | MLflow experiment, SQL warehouse, Serving Endpoint | | `appkit-analytics` | Node.js app with SQL analytics dashboards and charts | SQL warehouse | | `appkit-genie` | Node.js app with AI/BI Genie for natural language data queries | Genie Space | | `appkit-files` | Node.js app with file browser for Databricks Volumes | Volume | diff --git a/agent-langchain-ts/README.md b/agent-langchain-ts/README.md index 715fc2a2..c7602c35 100644 --- a/agent-langchain-ts/README.md +++ b/agent-langchain-ts/README.md @@ -1,363 +1,174 @@ # LangChain TypeScript Agent with MLflow Tracing -A production-ready TypeScript agent template using [@databricks/langchainjs](https://github.com/databricks/databricks-ai-bridge/tree/main/integrations/langchainjs) with automatic MLflow tracing via OpenTelemetry. +A standalone Express and LangChain agent template for Databricks Apps. It uses +`@mlflow/core@0.3.0` as its tracing provider and stores traces in a pre-provisioned +Unity Catalog trace location. -## Features +## What is included -- 🤖 **LangChain Agent**: Tool-calling agent using ChatDatabricks -- 📊 **MLflow Tracing**: Automatic trace export via OpenTelemetry -- 🔧 **Multiple Tools**: Built-in tools + MCP integration (SQL, UC Functions, Vector Search) -- 🚀 **Express API**: REST API with streaming support -- 📦 **TypeScript**: Full type safety with modern ES modules -- ☁️ **Databricks Deployment**: Ready for Databricks Apps platform +- A LangGraph ReAct agent backed by `ChatDatabricks` +- Built-in tools and optional MCP tools +- `/invocations` and `/responses` endpoints with streaming and non-streaming responses +- One semantic `AGENT` trace root per request +- Child spans for every LangChain model, chain, tool, and retriever lifecycle +- Exact token/cache aggregation, latency, time to first token, stream duration, and + provider cost when the provider returns one +- Bounded, redacted inputs, outputs, events, identities, and errors +- The actual MLflow V4 trace ID in `X-MLflow-Trace-Id` for every successful request; + non-streaming responses also include `trace_id` -> **Note**: This template uses a standalone Express.js server with OpenTelemetry tracing, rather than the MLflow AgentServer / ResponsesAgent pattern used by the other agent templates. It does not include the built-in chat UI proxy or the `/invocations`/`/responses` endpoints. See the [official agent authoring docs](https://docs.databricks.com/aws/en/generative-ai/agent-framework/author-agent) for the standard Apps-based agent pattern. +## Prerequisites -## Quick Start +- Node.js 22 or later +- `uv` +- Databricks CLI authentication +- A SQL warehouse that can provision the MLflow Unity Catalog trace tables -### Prerequisites +## Quickstart -- Node.js >= 18.0.0 -- Databricks workspace with Model Serving enabled -- Databricks CLI configured - -### Installation +From this directory, run: ```bash -npm install -``` - -### Configuration - -Copy the environment template and configure your settings: - -```bash -cp .env.example .env -``` - -Edit `.env` with your Databricks credentials: - -```env -DATABRICKS_HOST=https://your-workspace.cloud.databricks.com -DATABRICKS_TOKEN=dapi... -DATABRICKS_MODEL=databricks-claude-sonnet-4-5 -MLFLOW_EXPERIMENT_ID=your-experiment-id -``` - -### Local Development - -```bash -# Start the server -npm run dev - -# Server will be available at http://localhost:8000 -``` - -### Test the Agent - -```bash -# Health check -curl http://localhost:8000/health - -# Chat (non-streaming) -curl -X POST http://localhost:8000/api/chat \ - -H "Content-Type: application/json" \ - -d '{ - "messages": [ - {"role": "user", "content": "What is the weather in San Francisco?"} - ] - }' - -# Chat (streaming) -curl -X POST http://localhost:8000/api/chat \ - -H "Content-Type: application/json" \ - -d '{ - "messages": [ - {"role": "user", "content": "Calculate 25 * 48"} - ], - "stream": true - }' -``` - -## Architecture - -### Project Structure - +npm run quickstart ``` -agent-langchain-ts/ -├── src/ -│ ├── agent.ts # Agent setup and execution -│ ├── server.ts # Express API server -│ ├── tracing.ts # OpenTelemetry MLflow tracing -│ └── tools.ts # Tool definitions (basic + MCP) -├── scripts/ -│ └── quickstart.ts # Setup wizard -├── tests/ -│ └── agent.test.ts # Unit tests -├── app.yaml # Databricks App runtime config -├── databricks.yml # Databricks Asset Bundle config -├── package.json -├── tsconfig.json -└── README.md -``` - -### Components - -#### 1. **ChatDatabricks Model** (`src/agent.ts`) -The agent uses `ChatDatabricks` from `@databricks/langchainjs`: +The TypeScript wizard configures authentication and the model, then invokes the shared +Task 10 Python quickstart. That workflow provisions or reuses an experiment through the +supported Python MLflow API: -```typescript -import { ChatDatabricks } from "@databricks/langchainjs"; - -const model = new ChatDatabricks({ - model: "databricks-claude-sonnet-4-5", - temperature: 0.1, - maxTokens: 2000, -}); +```python +mlflow.set_experiment( + experiment_name=experiment_name, + trace_location=UnityCatalog( + catalog_name=catalog, + schema_name=schema, + table_prefix=table_prefix, + ), +) ``` -#### 2. **MLflow Tracing** (`src/tracing.ts`) - -Automatic trace export to MLflow via OpenTelemetry: - -```typescript -import { initializeMLflowTracing } from "./tracing.js"; - -const tracing = initializeMLflowTracing({ - serviceName: "langchain-agent-ts", - experimentId: process.env.MLFLOW_EXPERIMENT_ID, -}); -``` - -All LangChain operations (LLM calls, tool invocations, chain executions) are automatically traced. - -#### 3. **Tools** (`src/tools.ts`) - -**Basic Tools:** -- `get_weather`: Weather lookup -- `calculator`: Mathematical expressions -- `get_current_time`: Current time in any timezone - -**MCP Tools** (optional): -- Databricks SQL queries -- Unity Catalog functions -- Vector Search -- Genie Spaces - -#### 4. **Express Server** (`src/server.ts`) - -REST API with: -- `GET /health`: Health check -- `POST /api/chat`: Agent invocation (streaming or non-streaming) - -## Tool Configuration - -### Basic Tools Only - -Default configuration includes weather, calculator, and time tools. - -### Adding MCP Tools +It validates the experiment's immutable trace location, provisions the UC tables, applies +app-principal grants when the app already exists, and writes the complete tracing config to +`.env`, `app.yaml`, and `databricks.yml`. It does not call private trace-location endpoints. -#### Databricks SQL - -Enable SQL queries via MCP: +Defaults are: ```env -ENABLE_SQL_MCP=true +MLFLOW_TRACKING_URI=databricks +MLFLOW_UC_CATALOG=main +MLFLOW_UC_SCHEMA=agent_traces +MLFLOW_UC_TABLE_PREFIX=agents_on_apps ``` -#### Unity Catalog Functions - -Use UC functions as tools: +Set `MLFLOW_TRACING_SQL_WAREHOUSE_ID` before quickstart to select a warehouse +non-interactively. Set `MLFLOW_EXPERIMENT_NAME` to choose a custom experiment name. -```env -UC_FUNCTION_CATALOG=main -UC_FUNCTION_SCHEMA=default -UC_FUNCTION_NAME=my_function # Optional: specific function -``` +## Required runtime configuration -#### Vector Search +The server validates these values before it listens: -Query vector search indexes: +| Variable | Purpose | +|---|---| +| `MLFLOW_EXPERIMENT_ID` | UC-backed MLflow experiment ID | +| `MLFLOW_UC_CATALOG` | UC catalog containing trace tables | +| `MLFLOW_UC_SCHEMA` | UC schema containing trace tables | +| `MLFLOW_UC_TABLE_PREFIX` | Prefix used for the trace tables | -```env -VECTOR_SEARCH_CATALOG=main -VECTOR_SEARCH_SCHEMA=default -VECTOR_SEARCH_INDEX=my_index # Optional: specific index -``` - -#### Genie Spaces - -Integrate with Genie data understanding: - -```env -GENIE_SPACE_ID=your-space-id -``` +`MLFLOW_TRACKING_URI` defaults to `databricks`. Deployment also carries +`MLFLOW_TRACING_SQL_WAREHOUSE_ID` and `MLFLOW_OTEL_SPANS_TABLE` for provisioning and +verification. -## Deployment to Databricks +Missing or malformed required configuration is a startup error. Runtime export failures are +logged and do not change an otherwise successful agent response. -### 1. Validate Configuration +## Run locally ```bash -databricks bundle validate -t dev +npm install +npm run dev:agent ``` -### 2. Deploy the App - -```bash -databricks bundle deploy -t dev -``` +The agent listens at `http://localhost:5001` in local development. -### 3. View Deployment +Streaming request: ```bash -databricks apps list -databricks apps get db-agent-langchain-ts- +curl -i http://localhost:5001/invocations \ + -H 'Content-Type: application/json' \ + -H 'X-Session-Id: example-session' \ + -H 'X-User-Id: example-user' \ + -H 'X-Request-Id: example-request' \ + -d '{"input":[{"role":"user","content":"What time is it in Tokyo?"}],"stream":true}' ``` -### 4. View Logs +The response header contains a V4 identifier such as: -```bash -databricks apps logs db-agent-langchain-ts- --follow +```text +X-MLflow-Trace-Id: trace:/main.agent_traces.agents_on_apps/<32-hex-id> ``` -### 5. View Traces in MLflow +## Trace contract -Navigate to your workspace: -``` -/Users//agent-langchain-ts -``` +The request root is named `langchain.request` and has span type `AGENT`. Request headers are +mapped to MLflow metadata: -Traces will appear in the experiment with: -- Request/response data -- Tool invocations -- Latency metrics -- Token usage - -## API Reference - -### POST /api/chat - -Invoke the agent with a conversation. - -**Request Body:** -```typescript -{ - messages: Array<{ - role: "user" | "assistant"; - content: string; - }>; - stream?: boolean; // Default: false - config?: { - temperature?: number; - maxTokens?: number; - }; -} -``` +| Header | Trace metadata | +|---|---| +| `X-Session-Id` | `mlflow.trace.session` | +| `X-User-Id` | `mlflow.trace.user` | +| `X-Request-Id` | `appkit.request.id` | -**Response (Non-streaming):** -```typescript -{ - message: { - role: "assistant"; - content: string; - }; - intermediateSteps?: Array<{ - action: string; - observation: string; - }>; -} -``` +`appkit.app.name` comes from `DATABRICKS_APP_NAME`, or defaults to +`agent-langchain-ts`. Missing identity headers receive safe request-scoped defaults. -**Response (Streaming):** +Each LangChain start event creates one live child span keyed by `run_id`; its matching end or +error event finalizes that same span. Model spans record model/provider, exact input/output +and cache tokens, latency, time to first token, stream duration, finish reason, and cost. +When cost is unavailable, the span/root records `costAvailable=false` and omits `costUsd`. -Server-Sent Events (SSE) stream: -``` -data: {"chunk": "Hello"} -data: {"chunk": " there"} -data: {"done": true} -``` - -## Development +## Test and build -### Build +Focused tracing and endpoint tests: ```bash -npm run build +npm test -- --runInBand tests/framework/tracing.test.ts tests/framework/endpoints.test.ts ``` -Output in `dist/` directory. - -### Test +Build: ```bash -npm test +npm run build ``` -### Lint & Format +Deployed test: ```bash -npm run lint -npm run format +APP_URL=https://your-app.databricksapps.com \ + npm run test:e2e -- --runInBand tests/e2e/deployed.test.ts ``` -## Configuration Reference - -### Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `DATABRICKS_HOST` | Databricks workspace URL | Required | -| `DATABRICKS_TOKEN` | Personal access token | Required | -| `DATABRICKS_MODEL` | Model endpoint name | `databricks-claude-sonnet-4-5` | -| `USE_RESPONSES_API` | Use Responses API | `false` | -| `TEMPERATURE` | Model temperature (0-1) | `0.1` | -| `MAX_TOKENS` | Max generation tokens | `2000` | -| `MLFLOW_TRACKING_URI` | MLflow tracking URI | `databricks` | -| `MLFLOW_EXPERIMENT_ID` | Experiment ID for traces | Required | -| `PORT` | Server port | `8000` | - -### Model Options - -Available Databricks foundation models: -- `databricks-claude-sonnet-4-5` -- `databricks-gpt-5-2` -- `databricks-meta-llama-3-3-70b-instruct` +When `APP_URL` is absent, the deployed suite is collected and skipped. When present, it +invokes the app, checks the returned V4 trace ID, retrieves that trace through +`@mlflow/core`, and verifies the single `AGENT` root has inputs and outputs. -Or use your own custom model serving endpoint. +## Deploy -## Troubleshooting - -### Authentication Issues - -Ensure your Databricks CLI is configured: ```bash -databricks auth login --host https://your-workspace.cloud.databricks.com +npm run build +databricks bundle deploy -t dev +databricks bundle run agent_langchain_ts -t dev ``` -### MLflow Traces Not Appearing - -Check: -1. `MLFLOW_EXPERIMENT_ID` is set correctly -2. You have `CAN_MANAGE` permission on the experiment -3. Tracing initialized successfully (check logs) +If quickstart ran before the app existed, rerun it with the deployed app name so the shared +workflow can apply explicit UC grants: -### MCP Tools Not Loading - -Verify: -1. MCP environment variables are set correctly -2. You have appropriate permissions for the resources -3. Check server logs for specific errors - -## Learn More - -- [@databricks/langchainjs SDK](https://github.com/databricks/databricks-ai-bridge/tree/main/integrations/langchainjs) -- [LangChain.js Documentation](https://js.langchain.com/) -- [MLflow Tracing](https://mlflow.org/docs/latest/llm-tracking.html) -- [OpenTelemetry](https://opentelemetry.io/) -- [Databricks Apps](https://docs.databricks.com/en/dev-tools/databricks-apps/index.html) +```bash +MLFLOW_EXPERIMENT_NAME=/Users/you@example.com/agents-on-apps npm run quickstart +``` -## License +## Customize -Apache 2.0 +- Edit `src/agent.ts` to change the model, prompt, or agent behavior. +- Edit `src/tools.ts` to add tools. +- Edit `src/mcp-servers.ts` to configure Databricks MCP integrations. +- Keep tracing and HTTP lifecycle changes under `src/framework/` covered by framework tests. diff --git a/agent-langchain-ts/app.yaml b/agent-langchain-ts/app.yaml index a7c385c0..0713bd50 100644 --- a/agent-langchain-ts/app.yaml +++ b/agent-langchain-ts/app.yaml @@ -12,6 +12,16 @@ env: value: "databricks" - name: MLFLOW_EXPERIMENT_ID valueFrom: "experiment" + - name: MLFLOW_TRACING_SQL_WAREHOUSE_ID + valueFrom: "mlflow-tracing-warehouse" + - name: MLFLOW_UC_CATALOG + value: "main" + - name: MLFLOW_UC_SCHEMA + value: "agent_traces" + - name: MLFLOW_UC_TABLE_PREFIX + value: "agents_on_apps" + - name: MLFLOW_OTEL_SPANS_TABLE + value: "main.agent_traces.agents_on_apps_otel_spans" # Server configuration - name: PORT diff --git a/agent-langchain-ts/databricks.yml b/agent-langchain-ts/databricks.yml index 94658510..160ccab5 100644 --- a/agent-langchain-ts/databricks.yml +++ b/agent-langchain-ts/databricks.yml @@ -11,34 +11,56 @@ variables: default: "dev" mlflow_experiment_id: - description: "MLflow experiment ID for traces (optional - will be created if not provided)" + description: "UC-backed MLflow experiment ID written by npm run quickstart" default: "" + mlflow_tracing_warehouse_id: + description: "SQL warehouse used to provision and query MLflow UC traces" + default: "" + include: - resources/*.yml resources: - experiments: - agent_tracing_experiment: - name: /Users/${workspace.current_user.userName}/agent-langchain-ts - apps: agent_langchain_ts: name: agent-lc-ts-${var.resource_name_suffix} description: "TypeScript LangChain agent with MLflow tracing" source_code_path: ./ + config: + command: ["bash", "start.sh"] + env: + - name: MLFLOW_TRACKING_URI + value: "databricks" + - name: MLFLOW_EXPERIMENT_ID + value_from: "experiment" + - name: MLFLOW_TRACING_SQL_WAREHOUSE_ID + value_from: "mlflow-tracing-warehouse" + - name: MLFLOW_UC_CATALOG + value: "main" + - name: MLFLOW_UC_SCHEMA + value: "agent_traces" + - name: MLFLOW_UC_TABLE_PREFIX + value: "agents_on_apps" + - name: MLFLOW_OTEL_SPANS_TABLE + value: "main.agent_traces.agents_on_apps_otel_spans" resources: - name: serving-endpoint serving_endpoint: name: ${var.serving_endpoint_name} permission: CAN_QUERY - # MLflow experiment for tracing (references experiment defined above) + # UC-backed MLflow experiment provisioned by npm run quickstart - name: experiment experiment: - experiment_id: ${resources.experiments.agent_tracing_experiment.id} + experiment_id: ${var.mlflow_experiment_id} permission: CAN_MANAGE + - name: mlflow-tracing-warehouse + sql_warehouse: + id: ${var.mlflow_tracing_warehouse_id} + permission: CAN_USE + # Add additional resources here as needed: # - Unity Catalog tables, functions, or vector search indexes # - Genie spaces for natural language data queries diff --git a/agent-langchain-ts/package-lock.json b/agent-langchain-ts/package-lock.json index 325c28ee..74b144f5 100644 --- a/agent-langchain-ts/package-lock.json +++ b/agent-langchain-ts/package-lock.json @@ -9,16 +9,13 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { - "@arizeai/openinference-instrumentation-langchain": "^4.0.0", "@databricks/ai-sdk-provider": "^0.3.0", "@databricks/langchainjs": "^0.1.0", "@databricks/sdk-experimental": "0.15.0", "@langchain/core": "^1.1.8", "@langchain/langgraph": "^1.1.2", "@langchain/mcp-adapters": "^1.1.1", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/exporter-trace-otlp-proto": "^0.55.0", - "@opentelemetry/sdk-trace-node": "^1.28.0", + "@mlflow/core": "0.3.0", "ai": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -94,39 +91,6 @@ "zod": "^3.25.76 || ^4.1.8" } }, - "node_modules/@arizeai/openinference-core": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@arizeai/openinference-core/-/openinference-core-2.0.5.tgz", - "integrity": "sha512-BnufYaFqmG9twkz/9DHX9WTcOs7YvVAYaufau5tdjOT1c0Y8niJwmNWzV36phNPg3c7SmdD5OYLuzeAUN0T3pQ==", - "license": "Apache-2.0", - "dependencies": { - "@arizeai/openinference-semantic-conventions": "2.1.7", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.25.1" - } - }, - "node_modules/@arizeai/openinference-instrumentation-langchain": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@arizeai/openinference-instrumentation-langchain/-/openinference-instrumentation-langchain-4.0.6.tgz", - "integrity": "sha512-yvA7ObrNUjhUN8y37lO+Cr8Ef7Bq6NKKoChXPOaKG/IufwAAcXUowdEC40gipUelS3k3AOgxcIU2rfP+7f+YyQ==", - "license": "Apache-2.0", - "dependencies": { - "@arizeai/openinference-core": "2.0.5", - "@arizeai/openinference-semantic-conventions": "2.1.7", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.25.1", - "@opentelemetry/instrumentation": "^0.46.0" - }, - "peerDependencies": { - "@langchain/core": "^1.0.0 || ^0.3.0 || ^0.2.0" - } - }, - "node_modules/@arizeai/openinference-semantic-conventions": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@arizeai/openinference-semantic-conventions/-/openinference-semantic-conventions-2.1.7.tgz", - "integrity": "sha512-KyBfwxkSusPvxHBaW/TJ0japEbXCNziW9o6/IRKiPu+gp5TMKIagV2NKvt47rWYa4Jc0Nl+SvAPm+yxkdJqVbg==", - "license": "Apache-2.0" - }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1378,6 +1342,37 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://npm-proxy.cloud.databricks.com/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://npm-proxy.cloud.databricks.com/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", @@ -2007,6 +2002,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://npm-proxy.cloud.databricks.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@langchain/core": { "version": "1.1.27", "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.27.tgz", @@ -2220,505 +2225,2238 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/@langchain/textsplitters": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@langchain/textsplitters/-/textsplitters-0.1.0.tgz", - "integrity": "sha512-djI4uw9rlkAb5iMhtLED+xJebDdAG935AdP4eRTB02R7OB/act55Bj9wsskhZsvuyQRpO4O1wQOp85s6T6GWmw==", - "license": "MIT", + "node_modules/@langchain/textsplitters": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@langchain/textsplitters/-/textsplitters-0.1.0.tgz", + "integrity": "sha512-djI4uw9rlkAb5iMhtLED+xJebDdAG935AdP4eRTB02R7OB/act55Bj9wsskhZsvuyQRpO4O1wQOp85s6T6GWmw==", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.21 <0.4.0" + } + }, + "node_modules/@mlflow/core": { + "version": "0.3.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@mlflow/core/-/core-0.3.0.tgz", + "integrity": "sha512-KfLwQv9wvgA9eo0H/S+1JRrfb1+kv1L0rWenSaxQS+RlCJ8hEg3uc/sai0SdNB4SOqkkJNBM5WMD02iI22walg==", + "license": "Apache-2.0", + "dependencies": { + "@databricks/sdk-experimental": "0.15.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.205.0", + "@opentelemetry/otlp-transformer": "^0.205.0", + "@opentelemetry/sdk-node": "^0.205.0", + "@opentelemetry/sdk-trace-base": "^2.1.0", + "bignumber.js": "^9.0.0", + "fast-safe-stringify": "^2.1.1", + "ini": "^5.0.0" + }, + "bin": { + "mlflow-trace-daemon": "bundle/daemon.cjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.205.0.tgz", + "integrity": "sha512-bGtFzqiENO2GpJk988mOBMe0MfeNpTQjbLm/LBijas6VRyEDQarUzdBHpFlu89A25k1+BCntdWGsWTa9Ai4FyA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz", + "integrity": "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-transformer": "0.205.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz", + "integrity": "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.10.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", + "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-trace": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "2.10.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { + "version": "2.10.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.10.0.tgz", + "integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@mlflow/core/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@mlflow/core/node_modules/ini": { + "version": "5.0.0", + "resolved": "https://npm-proxy.cloud.databricks.com/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.0.tgz", + "integrity": "sha512-qOdO524oPMkUsOJTrsH9vz/HN3B5pKyW+9zIW51A9kDMVe7ON70drz1ouoyoyOcfzc+oxhkQ6jWmbyKnlWmYqA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.205.0.tgz", + "integrity": "sha512-jQlw7OHbqZ8zPt+pOrW2KGN7T55P50e3NXBMr4ckPOF+DWDwSy4W7mkG09GpYWlQAQ5C9BXg5gfUlv5ldTgWsw==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0", + "@opentelemetry/sdk-logs": "0.205.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz", + "integrity": "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-transformer": "0.205.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz", + "integrity": "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.205.0.tgz", + "integrity": "sha512-5JteMyVWiro4ghF0tHQjfE6OJcF7UBUcoEqX3UIQ5jutKP1H+fxFdyhqjjpmeHMFxzOHaYuLlNR1Bn7FOjGyJg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0", + "@opentelemetry/sdk-logs": "0.205.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz", + "integrity": "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-transformer": "0.205.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz", + "integrity": "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.205.0.tgz", + "integrity": "sha512-q3VS9wS+lpZ01txKxiDGBtBpTNge3YhbVEFDgem9ZQR9eI3EZ68+9tVZH9zJcSxI37nZPJ6lEEZO58yEjYZsVA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-trace-base": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz", + "integrity": "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-transformer": "0.205.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz", + "integrity": "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.205.0.tgz", + "integrity": "sha512-1Vxlo4lUwqSKYX+phFkXHKYR3DolFHxCku6lVMP1H8sVE3oj4wwmwxMzDsJ7zF+sXd8M0FCr+ckK4SnNNKkV+w==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.205.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-metrics": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz", + "integrity": "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-transformer": "0.205.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz", + "integrity": "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.205.0.tgz", + "integrity": "sha512-fFxNQ/HbbpLmh1pgU6HUVbFD1kNIjrkoluoKJkh88+gnmpFD92kMQ8WFNjPnSbjg2mNVnEkeKXgCYEowNW+p1w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-metrics": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz", + "integrity": "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-transformer": "0.205.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz", + "integrity": "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.205.0.tgz", + "integrity": "sha512-qIbNnedw9QfFjwpx4NQvdgjK3j3R2kWH/2T+7WXAm1IfMFe9fwatYxE61i7li4CIJKf8HgUC3GS8Du0C3D+AuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.205.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-metrics": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz", + "integrity": "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-transformer": "0.205.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz", + "integrity": "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/exporter-prometheus": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.205.0.tgz", + "integrity": "sha512-xsot/Qm9VLDTag4GEwAunD1XR1U8eBHTLAgO7IZNo2JuD/c/vL7xmDP7mQIUr6Lk3gtj/yGGIR2h3vhTeVzv4w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-metrics": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.205.0.tgz", + "integrity": "sha512-ZBksUk84CcQOuDJB65yu5A4PORkC4qEsskNwCrPZxDLeWjPOFZNSWt0E0jQxKCY8PskLhjNXJYo12YaqsYvGFA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz", + "integrity": "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-transformer": "0.205.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz", + "integrity": "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.205.0.tgz", + "integrity": "sha512-vr2bwwPCSc9u7rbKc74jR+DXFvyMFQo9o5zs+H/fgbK672Whw/1izUKVf+xfWOdJOvuwTnfWxy+VAY+4TSo74Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz", + "integrity": "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-transformer": "0.205.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz", + "integrity": "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", + "license": "Apache-2.0", "dependencies": { - "js-tiktoken": "^1.0.12" + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=18" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@langchain/core": ">=0.2.21 <0.4.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.0.tgz", - "integrity": "sha512-qOdO524oPMkUsOJTrsH9vz/HN3B5pKyW+9zIW51A9kDMVe7ON70drz1ouoyoyOcfzc+oxhkQ6jWmbyKnlWmYqA==", - "license": "MIT", + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", + "license": "Apache-2.0", "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" }, "engines": { - "node": ">=18" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, "engines": { - "node": ">=8.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.55.0.tgz", - "integrity": "sha512-3cpa+qI45VHYcA5c0bHM6VHo9gicv3p5mlLHNG3rLyjQU8b7e0st1rWtrUn3JbZ3DwwCfhKop4eQ9UuYlC6Pkg==", + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api": "^1.3.0" + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", - "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "license": "Apache-2.0", "engines": { "node": ">=14" + } + }, + "node_modules/@opentelemetry/exporter-zipkin": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.1.0.tgz", + "integrity": "sha512-0mEI0VDZrrX9t5RE1FhAyGz+jAGt96HSuXu73leswtY3L5YZD11gtcpARY2KAx/s6Z2+rj5Mhj566JsI2C7mfA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/core": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", - "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "1.28.0" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.55.0.tgz", - "integrity": "sha512-qxiJFP+bBZW3+goHCGkE1ZdW9gJU0fR7eQ6OP+Rz5oGtEBbq4nkGodhb7C9FJlEFlE2siPtCxoeupV0gtYynag==", + "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.28.0", - "@opentelemetry/otlp-exporter-base": "0.55.0", - "@opentelemetry/otlp-transformer": "0.55.0", - "@opentelemetry/resources": "1.28.0", - "@opentelemetry/sdk-trace-base": "1.28.0" + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.28.0.tgz", - "integrity": "sha512-ZLwRMV+fNDpVmF2WYUdBHlq0eOWtEaUJSusrzjGnBt7iSRvfjFE3RXYUZJrqou/wIDWV0DwQ5KIfYe9WXg9Xqw==", + "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "license": "Apache-2.0", "engines": { "node": ">=14" } }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.46.0.tgz", - "integrity": "sha512-a9TijXZZbk0vI5TGLZl+0kxyFfrXHhX6Svtz7Pp2/VBlCSKrazuULEyoJQrOknJyFWNMEmbbJgOciHCCpQcisw==", + "node_modules/@opentelemetry/otlp-grpc-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.205.0.tgz", + "integrity": "sha512-AeuLfrciGYffqsp4EUTdYYc6Ee2BQS+hr08mHZk1C524SFWx0WnfcTnV0NFXbVURUNU6DZu1DhS89zRRrcx/hg==", "license": "Apache-2.0", "dependencies": { - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "1.7.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" + "@grpc/grpc-js": "^1.7.1", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.55.0.tgz", - "integrity": "sha512-iHQI0Zzq3h1T6xUJTVFwmFl5Dt5y1es+fl4kM+k5T/3YvmVyeYkSiF+wHCg6oKrlUAJfk+t55kaAu3sYmt7ZYA==", + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.28.0", - "@opentelemetry/otlp-transformer": "0.55.0" + "@opentelemetry/api": "^1.3.0" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.28.0.tgz", - "integrity": "sha512-ZLwRMV+fNDpVmF2WYUdBHlq0eOWtEaUJSusrzjGnBt7iSRvfjFE3RXYUZJrqou/wIDWV0DwQ5KIfYe9WXg9Xqw==", + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz", + "integrity": "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==", "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-transformer": "0.205.0" + }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.55.0.tgz", - "integrity": "sha512-kVqEfxtp6mSN2Dhpy0REo1ghP4PYhC1kMHQJ2qVlO99Pc+aigELjZDfg7/YKmL71gR6wVGIeJfiql/eXL7sQPA==", + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz", + "integrity": "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.55.0", - "@opentelemetry/core": "1.28.0", - "@opentelemetry/resources": "1.28.0", - "@opentelemetry/sdk-logs": "0.55.0", - "@opentelemetry/sdk-metrics": "1.28.0", - "@opentelemetry/sdk-trace-base": "1.28.0", + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", "protobufjs": "^7.3.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.28.0.tgz", - "integrity": "sha512-ZLwRMV+fNDpVmF2WYUdBHlq0eOWtEaUJSusrzjGnBt7iSRvfjFE3RXYUZJrqou/wIDWV0DwQ5KIfYe9WXg9Xqw==", + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/@opentelemetry/propagator-b3": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.30.1.tgz", - "integrity": "sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==", + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.30.1" + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, - "node_modules/@opentelemetry/propagator-jaeger": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.30.1.tgz", - "integrity": "sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==", + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.30.1" + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", "engines": { "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-node": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-node/-/sdk-node-0.205.0.tgz", + "integrity": "sha512-Y4Wcs8scj/Wy1u61pX1ggqPXPtCsGaqx/UnFu7BtRQE1zCQR+b0h56K7I0jz7U2bRlPUZIFdnNLtoaJSMNzz2g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/exporter-logs-otlp-grpc": "0.205.0", + "@opentelemetry/exporter-logs-otlp-http": "0.205.0", + "@opentelemetry/exporter-logs-otlp-proto": "0.205.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "0.205.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.205.0", + "@opentelemetry/exporter-metrics-otlp-proto": "0.205.0", + "@opentelemetry/exporter-prometheus": "0.205.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.205.0", + "@opentelemetry/exporter-trace-otlp-http": "0.205.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.205.0", + "@opentelemetry/exporter-zipkin": "2.1.0", + "@opentelemetry/instrumentation": "0.205.0", + "@opentelemetry/propagator-b3": "2.1.0", + "@opentelemetry/propagator-jaeger": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "@opentelemetry/sdk-trace-node": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/resources": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.28.0.tgz", - "integrity": "sha512-cIyXSVJjGeTICENN40YSvLDAq4Y2502hGK3iN7tfdynQLKWb3XWZQEkPc+eSx47kiy11YeFAlYkEfXwR1w8kfw==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/api-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz", + "integrity": "sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.28.0", - "@opentelemetry/semantic-conventions": "1.27.0" + "@opentelemetry/api": "^1.3.0" }, "engines": { - "node": ">=14" + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/context-async-hooks": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.1.0.tgz", + "integrity": "sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.28.0.tgz", - "integrity": "sha512-ZLwRMV+fNDpVmF2WYUdBHlq0eOWtEaUJSusrzjGnBt7iSRvfjFE3RXYUZJrqou/wIDWV0DwQ5KIfYe9WXg9Xqw==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.205.0.tgz", + "integrity": "sha512-bGtFzqiENO2GpJk988mOBMe0MfeNpTQjbLm/LBijas6VRyEDQarUzdBHpFlu89A25k1+BCntdWGsWTa9Ai4FyA==", "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0" + }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.55.0.tgz", - "integrity": "sha512-TSx+Yg/d48uWW6HtjS1AD5x6WPfLhDWLl/WxC7I2fMevaiBuKCuraxTB8MDXieCNnBI24bw9ytyXrDCswFfWgA==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/instrumentation": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/instrumentation/-/instrumentation-0.205.0.tgz", + "integrity": "sha512-cgvm7tvQdu9Qo7VurJP84wJ7ZV9F6WqDDGZpUc6rUEXwjV7/bXWs0kaYp9v+1Vh1+3TZCD3i6j/lUBcPhu8NhA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.55.0", - "@opentelemetry/core": "1.28.0", - "@opentelemetry/resources": "1.28.0" + "@opentelemetry/api-logs": "0.205.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.28.0.tgz", - "integrity": "sha512-ZLwRMV+fNDpVmF2WYUdBHlq0eOWtEaUJSusrzjGnBt7iSRvfjFE3RXYUZJrqou/wIDWV0DwQ5KIfYe9WXg9Xqw==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz", + "integrity": "sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" + "@opentelemetry/core": "2.1.0", + "@opentelemetry/otlp-transformer": "0.205.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz", + "integrity": "sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/sdk-logs": "0.205.0", + "@opentelemetry/sdk-metrics": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/propagator-b3": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/propagator-b3/-/propagator-b3-2.1.0.tgz", + "integrity": "sha512-yOdHmFseIChYanddMMz0mJIFQHyjwbNhoxc65fEAA8yanxcBPwoFDoh1+WBUWAO/Z0NRgk+k87d+aFIzAZhcBw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/propagator-jaeger": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.1.0.tgz", + "integrity": "sha512-QYo7vLyMjrBCUTpwQBF/e+rvP7oGskrSELGxhSvLj5gpM0az9oJnu/0O4l2Nm7LEhAff80ntRYKkAcSwVgvSVQ==", "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0" + }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.28.0.tgz", - "integrity": "sha512-43tqMK/0BcKTyOvm15/WQ3HLr0Vu/ucAl/D84NO7iSlv6O4eOprxSHa3sUtmYkaZWHqdDJV0AHVz/R6u4JALVQ==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/resources": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.28.0", - "@opentelemetry/resources": "1.28.0" + "@opentelemetry/core": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.28.0.tgz", - "integrity": "sha512-ZLwRMV+fNDpVmF2WYUdBHlq0eOWtEaUJSusrzjGnBt7iSRvfjFE3RXYUZJrqou/wIDWV0DwQ5KIfYe9WXg9Xqw==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-logs": { + "version": "0.205.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz", + "integrity": "sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" + "@opentelemetry/api-logs": "0.205.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz", + "integrity": "sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==", "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0" + }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.28.0.tgz", - "integrity": "sha512-ceUVWuCpIao7Y5xE02Xs3nQi0tOGmMea17ecBdwtCvdo9ekmO+ijc9RFDgfifMl7XCBf41zne/1POM3LqSTZDA==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.28.0", - "@opentelemetry/resources": "1.28.0", - "@opentelemetry/semantic-conventions": "1.27.0" + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.28.0.tgz", - "integrity": "sha512-ZLwRMV+fNDpVmF2WYUdBHlq0eOWtEaUJSusrzjGnBt7iSRvfjFE3RXYUZJrqou/wIDWV0DwQ5KIfYe9WXg9Xqw==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.1.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.1.0.tgz", + "integrity": "sha512-SvVlBFc/jI96u/mmlKm86n9BbTCbQ35nsPoOohqJX6DXH92K0kTe73zGY5r8xoI1QkjR9PizszVJLzMC966y9Q==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" + "@opentelemetry/context-async-hooks": "2.1.0", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/sdk-trace-base": "2.1.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "license": "Apache-2.0", "engines": { "node": ">=14" } }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.30.1.tgz", - "integrity": "sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==", + "node_modules/@opentelemetry/sdk-node/node_modules/import-in-the-middle": { + "version": "1.15.0", + "resolved": "https://npm-proxy.cloud.databricks.com/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz", + "integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/context-async-hooks": "1.30.1", - "@opentelemetry/core": "1.30.1", - "@opentelemetry/propagator-b3": "1.30.1", - "@opentelemetry/propagator-jaeger": "1.30.1", - "@opentelemetry/sdk-trace-base": "1.30.1", - "semver": "^7.5.2" + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.10.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz", + "integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/resources": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", - "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/core": { + "version": "2.10.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/semantic-conventions": "1.28.0" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/sdk-trace-base": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", - "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/resources": { + "version": "2.10.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/resources/-/resources-2.10.0.tgz", + "integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/resources": "1.30.1", - "@opentelemetry/semantic-conventions": "1.28.0" + "@opentelemetry/core": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://npm-proxy.cloud.databricks.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -2736,7 +4474,7 @@ }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "resolved": "https://npm-proxy.cloud.databricks.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", "license": "BSD-3-Clause" }, @@ -2747,25 +4485,24 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://npm-proxy.cloud.databricks.com/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://npm-proxy.cloud.databricks.com/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://npm-proxy.cloud.databricks.com/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -2774,12 +4511,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -2793,9 +4524,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.2", + "resolved": "https://npm-proxy.cloud.databricks.com/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, "node_modules/@sinclair/typebox": { @@ -3050,12 +4781,6 @@ "@types/node": "*" } }, - "node_modules/@types/shimmer": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", - "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", - "license": "MIT" - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3353,11 +5078,10 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", - "deprecated": "package has been renamed to acorn-import-attributes", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://npm-proxy.cloud.databricks.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "license": "MIT", "peerDependencies": { "acorn": "^8" @@ -3946,7 +5670,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -4417,7 +6140,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4887,6 +6609,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://npm-proxy.cloud.databricks.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -5184,7 +6912,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5558,18 +7285,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-in-the-middle": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.7.1.tgz", - "integrity": "sha512-1LrZPDtW+atAxH42S6288qyDFNQ2YCty+2mxEPRtfazH6Z5QwkaBSTS2ods7hnVJioF6rkRfNoA6A/MstpFXLg==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.8.2", - "acorn-import-assertions": "^1.9.0", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" - } - }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -6820,6 +8535,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://npm-proxy.cloud.databricks.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7621,24 +9342,23 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.6.5", + "resolved": "https://npm-proxy.cloud.databricks.com/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -7740,7 +9460,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8057,12 +9776,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shimmer": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", - "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", - "license": "BSD-2-Clause" - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -8816,7 +10529,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -8867,7 +10579,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -8903,7 +10614,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -8935,7 +10645,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -8954,7 +10663,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/agent-langchain-ts/package.json b/agent-langchain-ts/package.json index f1dbda30..aad5f923 100644 --- a/agent-langchain-ts/package.json +++ b/agent-langchain-ts/package.json @@ -16,7 +16,7 @@ "build:agent": "tsc", "build:agent-only": "tsc", "build:ui": "cd ui && npm install && npm run build", - "test": "jest --testPathIgnorePatterns=examples", + "test": "NODE_NO_WARNINGS=1 NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=examples", "test:unit": "jest tests/agent.test.ts", "test:integration": "jest --runInBand tests/framework/integration.test.ts tests/framework/endpoints.test.ts tests/framework/error-handling.test.ts tests/framework/followup-questions.test.ts", "test:e2e": "jest --config jest.e2e.config.js", @@ -27,16 +27,13 @@ "format": "prettier --write \"src/**/*.ts\"" }, "dependencies": { - "@arizeai/openinference-instrumentation-langchain": "^4.0.0", "@databricks/ai-sdk-provider": "^0.3.0", "@databricks/langchainjs": "^0.1.0", "@databricks/sdk-experimental": "0.15.0", "@langchain/core": "^1.1.8", "@langchain/langgraph": "^1.1.2", "@langchain/mcp-adapters": "^1.1.1", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/exporter-trace-otlp-proto": "^0.55.0", - "@opentelemetry/sdk-trace-node": "^1.28.0", + "@mlflow/core": "0.3.0", "ai": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -66,7 +63,6 @@ "databricks", "langchain", "mlflow", - "opentelemetry", "tracing", "agent", "typescript" diff --git a/agent-langchain-ts/scripts/discover-tools.ts b/agent-langchain-ts/scripts/discover-tools.ts index 93dd74b7..132ccba6 100644 --- a/agent-langchain-ts/scripts/discover-tools.ts +++ b/agent-langchain-ts/scripts/discover-tools.ts @@ -14,6 +14,7 @@ import { WorkspaceClient } from "@databricks/sdk-experimental"; import { writeFileSync } from "fs"; import { config } from "dotenv"; +import { safeLogError } from "../src/framework/tracing.js"; // Load environment variables config(); @@ -36,7 +37,7 @@ interface DiscoveryResults { async function discoverUCFunctions( w: WorkspaceClient, catalog?: string, - maxSchemas: number = DEFAULT_MAX_SCHEMAS + maxSchemas: number = DEFAULT_MAX_SCHEMAS, ): Promise { const functions: any[] = []; let schemasSearched = 0; @@ -61,7 +62,10 @@ async function discoverUCFunctions( } // Take schemas from this catalog until we hit the global budget - const schemasToSearch = allSchemas.slice(0, maxSchemas - schemasSearched); + const schemasToSearch = allSchemas.slice( + 0, + maxSchemas - schemasSearched, + ); for (const schema of schemasToSearch) { const schema_name = `${cat}.${schema.name}`; @@ -91,7 +95,7 @@ async function discoverUCFunctions( } } } catch (error: any) { - console.error(`Error discovering UC functions: ${error.message}`); + console.error("Error discovering UC functions:", safeLogError(error)); } return functions; @@ -104,7 +108,7 @@ async function discoverUCTables( w: WorkspaceClient, catalog?: string, schema?: string, - maxSchemas: number = DEFAULT_MAX_SCHEMAS + maxSchemas: number = DEFAULT_MAX_SCHEMAS, ): Promise { const tables: any[] = []; let schemasSearched = 0; @@ -135,7 +139,10 @@ async function discoverUCTables( } // Take schemas until we hit the global budget - const schemasSlice = schemasToSearch.slice(0, maxSchemas - schemasSearched); + const schemasSlice = schemasToSearch.slice( + 0, + maxSchemas - schemasSearched, + ); for (const sch of schemasSlice) { if (sch === "information_schema") { @@ -181,7 +188,7 @@ async function discoverUCTables( } } } catch (error: any) { - console.error(`Error discovering UC tables: ${error.message}`); + console.error("Error discovering UC tables:", safeLogError(error)); } return tables; @@ -214,7 +221,10 @@ async function discoverVectorSearchIndexes(w: WorkspaceClient): Promise { } } } catch (error: any) { - console.error(`Error discovering vector search indexes: ${error.message}`); + console.error( + "Error discovering vector search indexes:", + safeLogError(error), + ); } return indexes; @@ -239,7 +249,7 @@ async function discoverGenieSpaces(w: WorkspaceClient): Promise { }); } } catch (error: any) { - console.error(`Error discovering Genie spaces: ${error.message}`); + console.error("Error discovering Genie spaces:", safeLogError(error)); } return spaces; @@ -265,7 +275,7 @@ async function discoverCustomMCPServers(w: WorkspaceClient): Promise { } } } catch (error: any) { - console.error(`Error discovering custom MCP servers: ${error.message}`); + console.error("Error discovering custom MCP servers:", safeLogError(error)); } return customServers; @@ -292,7 +302,10 @@ async function discoverExternalMCPServers(w: WorkspaceClient): Promise { } } } catch (error: any) { - console.error(`Error discovering external MCP servers: ${error.message}`); + console.error( + "Error discovering external MCP servers:", + safeLogError(error), + ); } return externalServers; @@ -308,10 +321,16 @@ function formatOutputMarkdown(results: DiscoveryResults): string { const functions = results.uc_functions; if (functions.length > 0) { lines.push(`## Unity Catalog Functions (${functions.length})\n`); - lines.push("**What they are:** SQL UDFs that can be used as agent tools.\n"); + lines.push( + "**What they are:** SQL UDFs that can be used as agent tools.\n", + ); lines.push("**How to use:** Access via UC functions MCP server:"); - lines.push("- All functions in a schema: `{workspace_host}/api/2.0/mcp/functions/{catalog}/{schema}`"); - lines.push("- Single function: `{workspace_host}/api/2.0/mcp/functions/{catalog}/{schema}/{function_name}`\n"); + lines.push( + "- All functions in a schema: `{workspace_host}/api/2.0/mcp/functions/{catalog}/{schema}`", + ); + lines.push( + "- Single function: `{workspace_host}/api/2.0/mcp/functions/{catalog}/{schema}/{function_name}`\n", + ); for (const func of functions.slice(0, 10)) { lines.push(`- \`${func.name}\``); if (func.comment) { @@ -349,9 +368,15 @@ function formatOutputMarkdown(results: DiscoveryResults): string { const indexes = results.vector_search_indexes; if (indexes.length > 0) { lines.push(`## Vector Search Indexes (${indexes.length})\n`); - lines.push("These can be used for RAG applications with unstructured data.\n"); - lines.push("**How to use:** Connect via MCP server at `{workspace_host}/api/2.0/mcp/vector-search/{catalog}/{schema}` or\n"); - lines.push("`{workspace_host}/api/2.0/mcp/vector-search/{catalog}/{schema}/{index_name}`\n"); + lines.push( + "These can be used for RAG applications with unstructured data.\n", + ); + lines.push( + "**How to use:** Connect via MCP server at `{workspace_host}/api/2.0/mcp/vector-search/{catalog}/{schema}` or\n", + ); + lines.push( + "`{workspace_host}/api/2.0/mcp/vector-search/{catalog}/{schema}/{index_name}`\n", + ); for (const idx of indexes) { lines.push(`- \`${idx.name}\``); lines.push(` - Endpoint: ${idx.endpoint}`); @@ -365,7 +390,9 @@ function formatOutputMarkdown(results: DiscoveryResults): string { if (spaces.length > 0) { lines.push(`## Genie Spaces (${spaces.length})\n`); lines.push("**What they are:** Natural language interface to your data\n"); - lines.push("**How to use:** Connect via Genie MCP server at `{workspace_host}/api/2.0/mcp/genie/{space_id}`\n"); + lines.push( + "**How to use:** Connect via Genie MCP server at `{workspace_host}/api/2.0/mcp/genie/{space_id}`\n", + ); for (const space of spaces) { lines.push(`- \`${space.name}\` (ID: ${space.id})`); if (space.description) { @@ -379,12 +406,22 @@ function formatOutputMarkdown(results: DiscoveryResults): string { const customServers = results.custom_mcp_servers; if (customServers.length > 0) { lines.push(`## Custom MCP Servers (${customServers.length})\n`); - lines.push("**What:** Your own MCP servers deployed as Databricks Apps (names starting with mcp-)\n"); + lines.push( + "**What:** Your own MCP servers deployed as Databricks Apps (names starting with mcp-)\n", + ); lines.push("**How to use:** Access via `{app_url}/mcp`\n"); - lines.push("**⚠️ Important:** Custom MCP server apps require manual permission grants:"); - lines.push("1. Get your agent app's service principal: `databricks apps get --output json | jq -r '.service_principal_name'`"); - lines.push("2. Grant permission: `databricks apps update-permissions --service-principal --permission-level CAN_USE`"); - lines.push("(Apps are not yet supported as resource dependencies in databricks.yml)\n"); + lines.push( + "**⚠️ Important:** Custom MCP server apps require manual permission grants:", + ); + lines.push( + "1. Get your agent app's service principal: `databricks apps get --output json | jq -r '.service_principal_name'`", + ); + lines.push( + "2. Grant permission: `databricks apps update-permissions --service-principal --permission-level CAN_USE`", + ); + lines.push( + "(Apps are not yet supported as resource dependencies in databricks.yml)\n", + ); for (const server of customServers) { lines.push(`- \`${server.name}\``); if (server.url) { @@ -404,9 +441,15 @@ function formatOutputMarkdown(results: DiscoveryResults): string { const externalServers = results.external_mcp_servers; if (externalServers.length > 0) { lines.push(`## External MCP Servers (${externalServers.length})\n`); - lines.push("**What:** Third-party MCP servers via Unity Catalog connections\n"); - lines.push("**How to use:** Connect via `{workspace_host}/api/2.0/mcp/external/{connection_name}`\n"); - lines.push("**Benefits:** Secure access to external APIs through UC governance\n"); + lines.push( + "**What:** Third-party MCP servers via Unity Catalog connections\n", + ); + lines.push( + "**How to use:** Connect via `{workspace_host}/api/2.0/mcp/external/{connection_name}`\n", + ); + lines.push( + "**Benefits:** Secure access to external APIs through UC governance\n", + ); for (const server of externalServers) { lines.push(`- \`${server.name}\``); if (server.full_name) { @@ -467,7 +510,9 @@ async function main() { ? new WorkspaceClient({ profile }) : new WorkspaceClient({ host: process.env.DATABRICKS_HOST, - authType: process.env.DATABRICKS_CONFIG_PROFILE ? "databricks-cli" : undefined, + authType: process.env.DATABRICKS_CONFIG_PROFILE + ? "databricks-cli" + : undefined, profile: process.env.DATABRICKS_CONFIG_PROFILE, }); @@ -482,22 +527,35 @@ async function main() { // Discover each type with configurable limits console.error("- UC Functions..."); - results.uc_functions = (await discoverUCFunctions(w, catalog, maxSchemas)).slice(0, maxResults); + results.uc_functions = ( + await discoverUCFunctions(w, catalog, maxSchemas) + ).slice(0, maxResults); console.error("- UC Tables..."); - results.uc_tables = (await discoverUCTables(w, catalog, schema, maxSchemas)).slice(0, maxResults); + results.uc_tables = ( + await discoverUCTables(w, catalog, schema, maxSchemas) + ).slice(0, maxResults); console.error("- Vector Search Indexes..."); - results.vector_search_indexes = (await discoverVectorSearchIndexes(w)).slice(0, maxResults); + results.vector_search_indexes = (await discoverVectorSearchIndexes(w)).slice( + 0, + maxResults, + ); console.error("- Genie Spaces..."); results.genie_spaces = (await discoverGenieSpaces(w)).slice(0, maxResults); console.error("- Custom MCP Servers (Apps)..."); - results.custom_mcp_servers = (await discoverCustomMCPServers(w)).slice(0, maxResults); + results.custom_mcp_servers = (await discoverCustomMCPServers(w)).slice( + 0, + maxResults, + ); console.error("- External MCP Servers (Connections)..."); - results.external_mcp_servers = (await discoverExternalMCPServers(w)).slice(0, maxResults); + results.external_mcp_servers = (await discoverExternalMCPServers(w)).slice( + 0, + maxResults, + ); // Format output let outputText: string; @@ -519,13 +577,15 @@ async function main() { console.error("\n=== Discovery Summary ==="); console.error(`UC Functions: ${results.uc_functions.length}`); console.error(`UC Tables: ${results.uc_tables.length}`); - console.error(`Vector Search Indexes: ${results.vector_search_indexes.length}`); + console.error( + `Vector Search Indexes: ${results.vector_search_indexes.length}`, + ); console.error(`Genie Spaces: ${results.genie_spaces.length}`); console.error(`Custom MCP Servers: ${results.custom_mcp_servers.length}`); console.error(`External MCP Servers: ${results.external_mcp_servers.length}`); } main().catch((error) => { - console.error("Fatal error:", error); + console.error("Fatal error:", safeLogError(error)); process.exit(1); }); diff --git a/agent-langchain-ts/scripts/quickstart.ts b/agent-langchain-ts/scripts/quickstart.ts index 706d7505..0a35ce01 100644 --- a/agent-langchain-ts/scripts/quickstart.ts +++ b/agent-langchain-ts/scripts/quickstart.ts @@ -10,11 +10,11 @@ * - Dependency installation */ -import { execSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import { readFileSync, writeFileSync, existsSync } from "fs"; import { join } from "path"; import * as readline from "readline/promises"; -import { WorkspaceClient } from "@databricks/sdk-experimental"; +import { safeLogError } from "../src/framework/tracing.js"; const rl = readline.createInterface({ input: process.stdin, @@ -25,7 +25,6 @@ interface Config { databricksHost: string; configProfile: string; model: string; - experimentId?: string; } interface DatabricksProfile { @@ -33,7 +32,10 @@ interface DatabricksProfile { host: string; } -async function prompt(question: string, defaultValue?: string): Promise { +async function prompt( + question: string, + defaultValue?: string, +): Promise { const promptText = defaultValue ? `${question} (${defaultValue}): ` : `${question}: `; @@ -92,7 +94,7 @@ async function setupEnvironment(): Promise { const choice = await prompt( `Select profile (1-${profiles.length + 1})`, - "1" + "1", ); const idx = parseInt(choice) - 1; @@ -107,16 +109,20 @@ async function setupEnvironment(): Promise { if (!config.configProfile) { const host = await prompt( "Databricks workspace URL", - "https://your-workspace.cloud.databricks.com" + "https://your-workspace.cloud.databricks.com", ); console.log("\nOpening browser for Databricks login..."); try { - execSync(`databricks auth login --host ${host} --profile DEFAULT`, { stdio: "inherit" }); + execSync(`databricks auth login --host ${host} --profile DEFAULT`, { + stdio: "inherit", + }); config.configProfile = "DEFAULT"; config.databricksHost = host; console.log(` ✅ Logged in and saved as profile: DEFAULT`); } catch { - console.error("❌ Login failed. Run 'databricks auth login' manually and re-run quickstart."); + console.error( + "❌ Login failed. Run 'databricks auth login' manually and re-run quickstart.", + ); process.exit(1); } } @@ -146,42 +152,6 @@ async function setupEnvironment(): Promise { console.log(` Using model: ${config.model}`); - // MLflow experiment - console.log("\n📊 MLflow Configuration"); - const createExperiment = await confirm( - "Create MLflow experiment?", - true - ); - - if (createExperiment) { - try { - const client = new WorkspaceClient({ profile: config.configProfile }); - - const me = await client.currentUser.me(); - const experimentPath = `/Users/${me.userName}/agent-langchain-ts`; - console.log(` Creating experiment: ${experimentPath}`); - - try { - const created = await client.experiments.createExperiment({ name: experimentPath }); - config.experimentId = created.experiment_id; - console.log(` ✅ Experiment created: ${config.experimentId}`); - } catch (createError: any) { - if (createError?.message?.includes("RESOURCE_ALREADY_EXISTS")) { - const existing = await client.experiments.getByName({ experiment_name: experimentPath }); - config.experimentId = existing.experiment?.experiment_id; - console.log(` ✅ Using existing experiment: ${config.experimentId}`); - } else { - throw createError; - } - } - } catch (error) { - console.log(" ⚠️ Could not auto-create experiment:", error); - config.experimentId = await prompt("Enter experiment ID (optional)"); - } - } else { - config.experimentId = await prompt("Enter experiment ID (optional)"); - } - return config; } @@ -206,10 +176,6 @@ function writeEnvFile(config: Config): void { envContent = envContent.replace(/^DATABRICKS_HOST=.*\n?/m, ""); envContent = envContent.replace(/^DATABRICKS_TOKEN=.*\n?/m, ""); - if (config.experimentId) { - updates.MLFLOW_EXPERIMENT_ID = config.experimentId; - } - // Replace or append variables for (const [key, value] of Object.entries(updates)) { const regex = new RegExp(`^${key}=.*$`, "m"); @@ -224,6 +190,52 @@ function writeEnvFile(config: Config): void { console.log(`\n✅ Environment configuration saved to .env`); } +function sharedPythonQuickstart(): string { + const candidates = [ + join(process.cwd(), "..", ".scripts", "source", "quickstart.py"), + join(process.cwd(), "..", "agent-langgraph", "scripts", "quickstart.py"), + ]; + const script = candidates.find((candidate) => existsSync(candidate)); + if (!script) { + throw new Error( + "Shared Task 10 Python quickstart was not found. Run this command from the app-templates checkout.", + ); + } + return script; +} + +function provisionMlflowUc(config: Config): void { + console.log("\n📊 Provisioning the MLflow Unity Catalog trace location..."); + const args = [ + "run", + "--no-project", + "--with", + "mlflow[databricks]>=3.14.0,<4", + "--with", + "ruamel.yaml>=0.18.0", + "python", + sharedPythonQuickstart(), + "--profile", + config.configProfile, + "--skip-lakebase", + "--mlflow-catalog", + process.env.MLFLOW_UC_CATALOG?.trim() || "main", + "--mlflow-schema", + process.env.MLFLOW_UC_SCHEMA?.trim() || "agent_traces", + "--mlflow-table-prefix", + process.env.MLFLOW_UC_TABLE_PREFIX?.trim() || "agents_on_apps", + ]; + const warehouseId = process.env.MLFLOW_TRACING_SQL_WAREHOUSE_ID?.trim(); + if (warehouseId) args.push("--mlflow-warehouse-id", warehouseId); + const experimentName = process.env.MLFLOW_EXPERIMENT_NAME?.trim(); + if (experimentName) args.push("--mlflow-experiment-name", experimentName); + + execFileSync("uv", args, { cwd: process.cwd(), stdio: "inherit" }); + console.log( + "✅ MLflow UC tracing provisioned through the shared supported workflow", + ); +} + async function installDependencies(): Promise { console.log("\n📦 Installing dependencies..."); @@ -251,6 +263,9 @@ async function main() { // Write .env file writeEnvFile(config); + // Provision the immutable UC-backed MLflow experiment through Task 10. + provisionMlflowUc(config); + // Install dependencies await installDependencies(); @@ -269,7 +284,7 @@ async function main() { console.log("\n📚 Documentation: README.md"); console.log(""); } catch (error) { - console.error("\n❌ Setup failed:", error); + console.error("\n❌ Setup failed:", safeLogError(error)); process.exit(1); } finally { rl.close(); diff --git a/agent-langchain-ts/src/agent.ts b/agent-langchain-ts/src/agent.ts index 20a2c609..5731d3c9 100644 --- a/agent-langchain-ts/src/agent.ts +++ b/agent-langchain-ts/src/agent.ts @@ -9,7 +9,11 @@ */ import { ChatDatabricks, DatabricksMCPServer } from "@databricks/langchainjs"; -import { BaseMessage, HumanMessage, SystemMessage } from "@langchain/core/messages"; +import { + BaseMessage, + HumanMessage, + SystemMessage, +} from "@langchain/core/messages"; import { createReactAgent } from "@langchain/langgraph/prebuilt"; import { randomUUID } from "crypto"; import type { @@ -21,8 +25,15 @@ import type { ResponseStreamEvent, ResponseTextDeltaEvent, } from "openai/resources/responses/responses.js"; -import type { AgentInterface, InvokeParams } from "./framework/agent-interface.js"; +import type { + AgentInterface, + InvokeParams, +} from "./framework/agent-interface.js"; import { getAllTools } from "./tools.js"; +import { + createLangChainTracingCallback, + sanitizePublicError, +} from "./framework/tracing.js"; /** * Agent configuration @@ -64,6 +75,8 @@ export interface AgentConfig { */ mcpServers?: DatabricksMCPServer[]; + /** Explicit Databricks SDK authentication for the model client. */ + auth?: ConstructorParameters[0]["auth"]; } /** @@ -105,7 +118,10 @@ export class StandardAgent implements AgentInterface { private agent: Awaited>; private systemPrompt: string; - constructor(agent: Awaited>, systemPrompt: string) { + constructor( + agent: Awaited>, + systemPrompt: string, + ) { this.agent = agent; this.systemPrompt = systemPrompt; } @@ -123,7 +139,15 @@ export class StandardAgent implements AgentInterface { new HumanMessage(input), ]; - const result = await this.agent.invoke({ messages }); + let result: Awaited>; + try { + result = await this.agent.invoke( + { messages }, + { callbacks: [createLangChainTracingCallback()] }, + ); + } catch (error) { + throw sanitizePublicError(error); + } const finalMessages = result.messages || []; const lastMessage = finalMessages[finalMessages.length - 1]; @@ -167,134 +191,140 @@ export class StandardAgent implements AgentInterface { const textItemId = `msg_${randomUUID()}`; let textOutputIndex = -1; // set on first text delta - const eventStream = this.agent.streamEvents({ messages }, { version: "v2" }); - - for await (const event of eventStream) { - // Tool call started — emit function_call output item - if (event.event === "on_tool_start") { - const callId = `call_${randomUUID()}`; - toolCallIds.set(`${event.name}_${event.run_id}`, callId); - - const fcItem: ResponseFunctionToolCall = { - id: `fc_${randomUUID()}`, - call_id: callId, - name: event.name, - arguments: JSON.stringify(event.data?.input || {}), - type: "function_call", - status: "completed", - }; + try { + const eventStream = this.agent.streamEvents( + { messages }, + { version: "v2", callbacks: [createLangChainTracingCallback()] }, + ); + for await (const event of eventStream) { + // Tool call started — emit function_call output item + if (event.event === "on_tool_start") { + const callId = `call_${randomUUID()}`; + toolCallIds.set(`${event.name}_${event.run_id}`, callId); + + const fcItem: ResponseFunctionToolCall = { + id: `fc_${randomUUID()}`, + call_id: callId, + name: event.name, + arguments: JSON.stringify(event.data?.input || {}), + type: "function_call", + status: "completed", + }; - const currentIndex = outputIndex++; + const currentIndex = outputIndex++; - const added: ResponseOutputItemAddedEvent = { - type: "response.output_item.added", - item: fcItem, - output_index: currentIndex, - sequence_number: seqNum++, - }; - yield added; + const added: ResponseOutputItemAddedEvent = { + type: "response.output_item.added", + item: fcItem, + output_index: currentIndex, + sequence_number: seqNum++, + }; + yield added; - const done: ResponseOutputItemDoneEvent = { - type: "response.output_item.done", - item: fcItem, - output_index: currentIndex, - sequence_number: seqNum++, - }; - yield done; - } + const done: ResponseOutputItemDoneEvent = { + type: "response.output_item.done", + item: fcItem, + output_index: currentIndex, + sequence_number: seqNum++, + }; + yield done; + } - // Tool result received — emit function_call_output item - if (event.event === "on_tool_end") { - const toolKey = `${event.name}_${event.run_id}`; - const callId = toolCallIds.get(toolKey) || `call_${randomUUID()}`; - toolCallIds.delete(toolKey); - - const outputItem = { - id: `fco_${randomUUID()}`, - call_id: callId, - output: JSON.stringify(event.data?.output || ""), - type: "function_call_output" as const, - }; + // Tool result received — emit function_call_output item + if (event.event === "on_tool_end") { + const toolKey = `${event.name}_${event.run_id}`; + const callId = toolCallIds.get(toolKey) || `call_${randomUUID()}`; + toolCallIds.delete(toolKey); + + const outputItem = { + id: `fco_${randomUUID()}`, + call_id: callId, + output: JSON.stringify(event.data?.output || ""), + type: "function_call_output" as const, + }; - const currentIndex = outputIndex++; + const currentIndex = outputIndex++; - yield { - type: "response.output_item.added", - item: outputItem, - output_index: currentIndex, - sequence_number: seqNum++, - } as unknown as ResponseStreamEvent; + yield { + type: "response.output_item.added", + item: outputItem, + output_index: currentIndex, + sequence_number: seqNum++, + } as unknown as ResponseStreamEvent; - yield { - type: "response.output_item.done", - item: outputItem, - output_index: currentIndex, - sequence_number: seqNum++, - } as unknown as ResponseStreamEvent; - } + yield { + type: "response.output_item.done", + item: outputItem, + output_index: currentIndex, + sequence_number: seqNum++, + } as unknown as ResponseStreamEvent; + } - // Text chunk from LLM - if (event.event === "on_chat_model_stream") { - const content = event.data?.chunk?.content; - if (content && typeof content === "string") { - // Emit output_item.added for the text message on first delta - if (textOutputIndex === -1) { - textOutputIndex = outputIndex++; - - const msgItem: ResponseOutputMessage = { - id: textItemId, - type: "message", - role: "assistant", - status: "in_progress", - content: [], - }; - const added: ResponseOutputItemAddedEvent = { - type: "response.output_item.added", - item: msgItem, + // Text chunk from LLM + if (event.event === "on_chat_model_stream") { + const content = event.data?.chunk?.content; + if (content && typeof content === "string") { + // Emit output_item.added for the text message on first delta + if (textOutputIndex === -1) { + textOutputIndex = outputIndex++; + + const msgItem: ResponseOutputMessage = { + id: textItemId, + type: "message", + role: "assistant", + status: "in_progress", + content: [], + }; + const added: ResponseOutputItemAddedEvent = { + type: "response.output_item.added", + item: msgItem, + output_index: textOutputIndex, + sequence_number: seqNum++, + }; + yield added; + } + + const delta: ResponseTextDeltaEvent = { + type: "response.output_text.delta", + item_id: textItemId, output_index: textOutputIndex, + content_index: 0, + delta: content, + logprobs: [], sequence_number: seqNum++, }; - yield added; + yield delta; } - - const delta: ResponseTextDeltaEvent = { - type: "response.output_text.delta", - item_id: textItemId, - output_index: textOutputIndex, - content_index: 0, - delta: content, - logprobs: [], - sequence_number: seqNum++, - }; - yield delta; } } - } - // Close the text output item if we streamed any text - if (textOutputIndex !== -1) { - const msgItem: ResponseOutputMessage = { - id: textItemId, - type: "message", - role: "assistant", - status: "completed", - content: [], - }; - const done: ResponseOutputItemDoneEvent = { - type: "response.output_item.done", - item: msgItem, - output_index: textOutputIndex, + // Close the text output item if we streamed any text + if (textOutputIndex !== -1) { + const msgItem: ResponseOutputMessage = { + id: textItemId, + type: "message", + role: "assistant", + status: "completed", + content: [], + }; + const done: ResponseOutputItemDoneEvent = { + type: "response.output_item.done", + item: msgItem, + output_index: textOutputIndex, + sequence_number: seqNum++, + }; + yield done; + } + + // Signal end of response. + yield { + type: "response.completed", sequence_number: seqNum++, - }; - yield done; + response: {} as any, + } as unknown as ResponseStreamEvent; + } catch (error) { + throw sanitizePublicError(error); } - - // Signal end of response. - yield { - type: "response.completed", - sequence_number: seqNum++, - response: {} as any, - } as unknown as ResponseStreamEvent; } } @@ -310,7 +340,9 @@ export class StandardAgent implements AgentInterface { * @param config Agent configuration * @returns AgentInterface instance */ -export async function createAgent(config: AgentConfig = {}): Promise { +export async function createAgent( + config: AgentConfig = {}, +): Promise { const { model: modelName = "databricks-claude-sonnet-4-5", useResponsesApi = false, @@ -318,6 +350,7 @@ export async function createAgent(config: AgentConfig = {}): Promise { + throw sanitizePublicError(error); + }, + }, }); // Load tools (basic + MCP if configured) @@ -342,4 +381,3 @@ export async function createAgent(config: AgentConfig = {}): Promise { +export function createInvocationsRouter( + agent: AgentInterface, +): ReturnType { const router = Router(); router.post("/", async (req: Request, res: Response) => { @@ -86,7 +94,9 @@ export function createInvocationsRouter(agent: AgentInterface): ReturnType part.type === "input_text" || part.type === "text") + .filter( + (part: any) => part.type === "input_text" || part.type === "text", + ) .map((part: any) => part.text) .join("\n"); } else { @@ -110,17 +120,19 @@ export function createInvocationsRouter(agent: AgentInterface): ReturnType - part.type === "input_text" || - part.type === "output_text" || - part.type === "text" + .filter( + (part: any) => + part.type === "input_text" || + part.type === "output_text" || + part.type === "text", ) .map((part: any) => part.text); const toolParts = item.content - .filter((part: any) => - part.type === "function_call" || - part.type === "function_call_output" + .filter( + (part: any) => + part.type === "function_call" || + part.type === "function_call_output", ) .map((part: any) => { if (part.type === "function_call") { @@ -131,45 +143,76 @@ export function createInvocationsRouter(agent: AgentInterface): ReturnType p.length > 0); + const allParts = [...textParts, ...toolParts].filter( + (p) => p.length > 0, + ); return { ...item, content: allParts.join("\n") }; } return item; }); const agentParams = { input: userInput, chat_history: chatHistory }; + const requestId = req.get("x-request-id")?.trim() || randomUUID(); + const identity = { + requestId, + sessionId: req.get("x-session-id")?.trim() || requestId, + userId: + req.get("x-user-id")?.trim() || + req.get("x-forwarded-user")?.trim() || + "anonymous", + }; // Streaming response: write each ResponseStreamEvent directly as SSE if (stream) { - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - - try { - for await (const event of agent.stream(agentParams)) { - res.write(`data: ${JSON.stringify(event)}\n\n`); + await withAgentRequestTrace(req.body, identity, async (trace) => { + res.setHeader("X-MLflow-Trace-Id", trace.traceId); + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + + const events = new BoundedTraceAccumulator(); + try { + for await (const event of agent.stream(agentParams)) { + events.add(event); + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + trace.setOutputs(events.snapshot()); + res.write("data: [DONE]\n\n"); + res.end(); + } catch (error: unknown) { + const details = safeLogError(error); + const message = trace.recordError(error); + console.error("Streaming error:", { ...details, message }); + res.write( + `data: ${JSON.stringify({ type: "error", error: message })}\n\n`, + ); + res.write( + `data: ${JSON.stringify({ type: "response.failed" })}\n\n`, + ); + res.write("data: [DONE]\n\n"); + res.end(); } - res.write("data: [DONE]\n\n"); - res.end(); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error("Streaming error:", error); - res.write(`data: ${JSON.stringify({ type: "error", error: message })}\n\n`); - res.write(`data: ${JSON.stringify({ type: "response.failed" })}\n\n`); - res.write("data: [DONE]\n\n"); - res.end(); - } + }); } else { // Non-streaming response: return output items directly - const items = await agent.invoke(agentParams); - res.json({ output: items }); + const traced = await withAgentRequestTrace( + req.body, + identity, + async (trace) => { + res.setHeader("X-MLflow-Trace-Id", trace.traceId); + const items = await agent.invoke(agentParams); + trace.setOutputs(items); + return items; + }, + ); + res.json({ output: traced.value, trace_id: traced.traceId }); } } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error("Agent invocation error:", error); + const details = safeLogError(error); + console.error("Agent invocation error:", details); res.status(500).json({ error: "Internal server error", - message, + message: details.message, }); } }); diff --git a/agent-langchain-ts/src/framework/server.ts b/agent-langchain-ts/src/framework/server.ts index cb9ec0a3..a9da30c5 100644 --- a/agent-langchain-ts/src/framework/server.ts +++ b/agent-langchain-ts/src/framework/server.ts @@ -4,7 +4,7 @@ * Provides: * - /invocations endpoint (MLflow-compatible Responses API) * - Health check endpoint - * - MLflow trace export via OpenTelemetry + * - MLflow trace export via the supported MLflow TypeScript SDK * * Note: This server is UI-agnostic. The UI (e2e-chatbot-app-next) runs separately * and proxies to /invocations via the API_PROXY environment variable. @@ -16,10 +16,7 @@ import { config } from "dotenv"; import path from "path"; import { fileURLToPath } from "url"; import { existsSync } from "fs"; -import { - initializeMLflowTracing, - type MLflowTracing, -} from "./tracing.js"; +import { flushTracing, initializeTracing, safeLogError } from "./tracing.js"; import { createInvocationsRouter } from "./routes/invocations.js"; import { closeMCPClient } from "../tools.js"; import type { AgentInterface } from "./agent-interface.js"; @@ -46,23 +43,22 @@ const SERVICE_INFO = { /** * Register SIGINT/SIGTERM handlers that flush tracing and close MCP connections. */ -function setupShutdownHandlers(tracing: MLflowTracing): void { +function setupShutdownHandlers(): void { const shutdown = async (signal: string) => { console.log(`\nReceived ${signal}, shutting down...`); try { await closeMCPClient(); - await tracing.flush(); - await tracing.shutdown(); + await flushTracing(); process.exit(0); } catch (error) { - console.error("Error during shutdown:", error); + console.error("Error during shutdown:", safeLogError(error)); process.exit(1); } }; process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); - process.on("beforeExit", () => tracing.flush()); + process.on("beforeExit", () => flushTracing()); } /** @@ -70,21 +66,18 @@ function setupShutdownHandlers(tracing: MLflowTracing): void { */ export async function createServer( agent: AgentInterface, - serverConfig: ServerConfig + serverConfig: ServerConfig, ): Promise { const app = express(); // Middleware app.use(cors()); - app.use(express.json({ limit: '10mb' })); // Protect against large payload DoS + app.use(express.json({ limit: "10mb" })); // Protect against large payload DoS // Initialize MLflow tracing - const tracing = await initializeMLflowTracing({ - serviceName: "langchain-agent-ts", - experimentId: process.env.MLFLOW_EXPERIMENT_ID, - }); + initializeTracing(); - setupShutdownHandlers(tracing); + setupShutdownHandlers(); /** * Health check endpoint @@ -116,7 +109,10 @@ export async function createServer( const response = await fetch(targetUrl, { method: req.method, headers: req.headers as Record, - body: req.method !== "GET" && req.method !== "HEAD" ? JSON.stringify(req.body) : undefined, + body: + req.method !== "GET" && req.method !== "HEAD" + ? JSON.stringify(req.body) + : undefined, }); // Copy response headers @@ -137,7 +133,7 @@ export async function createServer( } res.end(); } catch (error) { - console.error("Error proxying to UI backend:", error); + console.error("Error proxying to UI backend:", safeLogError(error)); res.status(502).json({ error: "Bad Gateway" }); } }); @@ -145,7 +141,15 @@ export async function createServer( // Serve UI static files from ui/client/dist const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - const uiDistPath = path.join(__dirname, "..", "..", "..", "ui", "client", "dist"); + const uiDistPath = path.join( + __dirname, + "..", + "..", + "..", + "ui", + "client", + "dist", + ); if (existsSync(uiDistPath)) { console.log(`📂 Serving UI static files from: ${uiDistPath}`); @@ -157,11 +161,15 @@ export async function createServer( }); } else { console.warn(`⚠️ UI dist path not found: ${uiDistPath}`); - app.get("/", (_req: Request, res: Response) => { res.json(SERVICE_INFO); }); + app.get("/", (_req: Request, res: Response) => { + res.json(SERVICE_INFO); + }); } } else { // Agent-only mode: service info at root - app.get("/", (_req: Request, res: Response) => { res.json(SERVICE_INFO); }); + app.get("/", (_req: Request, res: Response) => { + res.json(SERVICE_INFO); + }); } return app; @@ -170,7 +178,10 @@ export async function createServer( /** * Start the server */ -export async function startServer(agent: AgentInterface, config?: { port?: number }) { +export async function startServer( + agent: AgentInterface, + config?: { port?: number }, +) { const port = config?.port ?? parseInt(process.env.PORT || "8000", 10); const app = await createServer(agent, { port }); @@ -180,6 +191,8 @@ export async function startServer(agent: AgentInterface, config?: { port?: numbe console.log(` Health: http://localhost:${port}/health`); console.log(` Invocations API: http://localhost:${port}/invocations`); console.log(`\n📊 MLflow tracking enabled`); - console.log(` Experiment: ${process.env.MLFLOW_EXPERIMENT_ID || "default"}`); + console.log( + ` Experiment: ${process.env.MLFLOW_EXPERIMENT_ID || "default"}`, + ); }); } diff --git a/agent-langchain-ts/src/framework/tracing.ts b/agent-langchain-ts/src/framework/tracing.ts index b2bffa6f..f9380f07 100644 --- a/agent-langchain-ts/src/framework/tracing.ts +++ b/agent-langchain-ts/src/framework/tracing.ts @@ -1,393 +1,927 @@ -/** - * MLflow tracing setup using OpenTelemetry for LangChain instrumentation. - * - * This module configures automatic trace export to MLflow, capturing: - * - LangChain operations (LLM calls, tool invocations, chain executions) - * - Span timing and hierarchy - * - Input/output data - * - Metadata and attributes - */ - -import { - NodeTracerProvider, - SimpleSpanProcessor, - BatchSpanProcessor, -} from "@opentelemetry/sdk-trace-node"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; -import { LangChainInstrumentation } from "@arizeai/openinference-instrumentation-langchain"; -import * as CallbackManagerModule from "@langchain/core/callbacks/manager"; -import { Resource } from "@opentelemetry/resources"; -import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; -import { WorkspaceClient } from "@databricks/sdk-experimental"; - -export interface TracingConfig { - /** MLflow tracking URI (defaults to "databricks") */ - mlflowTrackingUri?: string; - - /** MLflow experiment ID to associate traces with */ - experimentId?: string; - - /** - * MLflow run ID to nest traces under (optional) - */ - runId?: string; - - /** - * Service name for trace identification - */ - serviceName?: string; - - /** - * Whether to use batch or simple span processor - * Batch is more efficient for production, simple is better for debugging - */ - useBatchProcessor?: boolean; -} - -export class MLflowTracing { - private provider: NodeTracerProvider; - private exporter!: OTLPTraceExporter; // Will be initialized in initialize() - private isInitialized = false; - private databricksClient?: WorkspaceClient; - - constructor(private config: TracingConfig = {}) { - // Set defaults - this.config.mlflowTrackingUri = config.mlflowTrackingUri || - process.env.MLFLOW_TRACKING_URI || - "databricks"; - this.config.experimentId = config.experimentId || - process.env.MLFLOW_EXPERIMENT_ID; - this.config.runId = config.runId || - process.env.MLFLOW_RUN_ID; - this.config.serviceName = config.serviceName || - "langchain-agent-ts"; - this.config.useBatchProcessor = config.useBatchProcessor ?? (process.env.OTEL_USE_BATCH_PROCESSOR !== "false"); - - // Note: Exporter will be created in initialize() after fetching auth token - this.provider = new NodeTracerProvider({ - resource: new Resource({ - [ATTR_SERVICE_NAME]: this.config.serviceName, - }), - }); +import * as mlflow from "@mlflow/core"; +import { BaseCallbackHandler } from "@langchain/core/callbacks/base"; +import { createHash } from "crypto"; + +const MAX_CAPTURE_BYTES = 64 * 1024; +const SECRET_KEY = + /(?:authorization|api[-_]?key|cookie|credential|password|secret|token)/i; +const SECRET_TEXT_FIELD = + "(?:authorization(?:[ _-]?header)?|(?:set[ _-]?)?cookie(?:[ _-]?header)?|(?:x[ _-]?)?api[ _-]?key|(?:databricks|access|refresh)[ _-]?token|client[ _-]?secret|password|secret|credential)"; +const SECRET_TEXT = new RegExp( + `((? { - if (!this.config.experimentId || !this.databricksClient) { - return null; +function resolveTrackingUri(): string { + const value = process.env.MLFLOW_TRACKING_URI?.trim() || "databricks"; + if (value === "databricks" || /^databricks:\/\/[^/\s]+$/.test(value)) + return value; + try { + const url = new URL(value); + if ( + (url.protocol === "http:" || url.protocol === "https:") && + url.hostname + ) { + return value; } + } catch { + // Fall through to the startup error below. + } + throw new Error( + "Invalid tracing environment variable MLFLOW_TRACKING_URI: expected databricks, databricks://, or an HTTP(S) URL", + ); +} - try { - await this.databricksClient.apiClient.request({ - path: `/api/4.0/mlflow/traces/${this.config.experimentId}/link-location`, - method: "POST", - headers: new Headers({ "Content-Type": "application/json" }), - payload: { - experiment_id: this.config.experimentId, - uc_schema: { - catalog_name: catalogName, - schema_name: schemaName, - }, - }, - raw: false, - }); +export function buildTracingConfig() { + return { + trackingUri: resolveTrackingUri(), + experimentId: requireExperimentId(), + traceLocation: { + catalogName: requireUcIdentifier("MLFLOW_UC_CATALOG"), + schemaName: requireUcIdentifier("MLFLOW_UC_SCHEMA"), + tablePrefix: requireUcIdentifier("MLFLOW_UC_TABLE_PREFIX"), + }, + }; +} + +export function initializeTracing(): void { + mlflow.init(buildTracingConfig()); +} + +export function setTraceIdentity( + sessionId: string, + userId: string, + requestId: string, +): void { + mlflow.updateCurrentTrace({ + metadata: { + "mlflow.trace.session": safeIdentity(sessionId), + "mlflow.trace.user": safeIdentity(userId), + "appkit.app.name": safeIdentity( + process.env.DATABRICKS_APP_NAME ?? "agent-langchain-ts", + ), + "appkit.request.id": safeIdentity(requestId), + }, + }); +} + +export interface TraceIdentity { + sessionId: string; + userId: string; + requestId: string; +} - console.log(`✅ Experiment linked to UC trace location: ${tableName}`); - return tableName; +export interface AgentRequestTrace { + traceId: string; + setOutputs(outputs: unknown): void; + recordError(error: unknown): string; +} - } catch (error) { - console.warn(`⚠️ Error linking experiment to trace location:`, error); - return null; +interface NormalizedUsage { + inputTokens: number; + outputTokens: number; + totalTokens: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + costAvailable: boolean; + costUsd?: number; +} + +class RequestTraceState { + private modelSteps = 0; + private inputTokens = 0; + private outputTokens = 0; + private totalTokens = 0; + private cacheReadInputTokens = 0; + private cacheCreationInputTokens = 0; + private hasCacheRead = false; + private hasCacheCreation = false; + private costAvailable = true; + private costUsd = 0; + + constructor(readonly span: mlflow.LiveSpan) {} + + addModelUsage(usage: NormalizedUsage): void { + this.modelSteps += 1; + this.inputTokens += usage.inputTokens; + this.outputTokens += usage.outputTokens; + this.totalTokens += usage.totalTokens; + if (usage.cacheReadInputTokens !== undefined) { + this.hasCacheRead = true; + this.cacheReadInputTokens += usage.cacheReadInputTokens; + } + if (usage.cacheCreationInputTokens !== undefined) { + this.hasCacheCreation = true; + this.cacheCreationInputTokens += usage.cacheCreationInputTokens; + } + if (!usage.costAvailable || usage.costUsd === undefined) { + this.costAvailable = false; + } else { + this.costUsd += usage.costUsd; } } - /** - * Set up experiment trace location in Unity Catalog - * Creates UC storage location and links experiment to it - * - * This implements the MLflow set_experiment_trace_location() API in TypeScript - */ - private async setupExperimentTraceLocation(): Promise { - if (!this.config.experimentId || !this.databricksClient) { - return null; + finalize(): void { + const usage: NormalizedUsage = { + inputTokens: this.inputTokens, + outputTokens: this.outputTokens, + totalTokens: this.totalTokens, + costAvailable: this.modelSteps > 0 && this.costAvailable, + }; + const tokenUsage: Record = { + input_tokens: this.inputTokens, + output_tokens: this.outputTokens, + total_tokens: this.totalTokens, + }; + if (this.hasCacheRead) { + usage.cacheReadInputTokens = this.cacheReadInputTokens; + tokenUsage.cache_read_input_tokens = this.cacheReadInputTokens; + } + if (this.hasCacheCreation) { + usage.cacheCreationInputTokens = this.cacheCreationInputTokens; + tokenUsage.cache_creation_input_tokens = this.cacheCreationInputTokens; } + if (usage.costAvailable) usage.costUsd = Number(this.costUsd.toFixed(12)); + this.span.setAttribute("appkit.usage", usage); + this.span.setAttribute("mlflow.chat.tokenUsage", tokenUsage); + } +} - const catalogName = process.env.OTEL_UC_CATALOG || "main"; - const schemaName = process.env.OTEL_UC_SCHEMA || "agent_traces"; - const warehouseId = process.env.MLFLOW_TRACING_SQL_WAREHOUSE_ID; - const tableName = `${catalogName}.${schemaName}.mlflow_experiment_trace_otel_spans`; +const requestTraces = new Map(); - // If no warehouse is specified, try to link directly (works if table already exists) - if (!warehouseId) { - console.log(`⚠️ MLFLOW_TRACING_SQL_WAREHOUSE_ID not set, attempting to link to existing table: ${tableName}`); - return await this.linkExperimentToLocation(catalogName, schemaName, tableName); - } +interface RunState { + span: mlflow.LiveSpan; + startedNs: bigint; + firstTokenNs?: bigint; + model?: string; + provider?: string; +} - try { - console.log(`🔗 Setting up trace location: ${catalogName}.${schemaName}`); - - // Step 1: Create UC storage location - await this.databricksClient.apiClient.request({ - path: "/api/4.0/mlflow/traces/location", - method: "POST", - headers: new Headers({ "Content-Type": "application/json" }), - payload: { - uc_schema: { - catalog_name: catalogName, - schema_name: schemaName, - }, - sql_warehouse_id: warehouseId, - }, - raw: false, - }); +export class LangChainTracingCallback extends BaseCallbackHandler { + name = "mlflow-core-langchain"; + private readonly root: mlflow.LiveSpan | null; + private readonly requestTrace?: RequestTraceState; + private readonly runs = new Map(); + + constructor() { + super(); + this.root = mlflow.getCurrentActiveSpan(); + this.requestTrace = this.root + ? requestTraces.get(this.root.traceId) + : undefined; + } - return await this.linkExperimentToLocation(catalogName, schemaName, tableName); + handleChainStart( + chain: any, + inputs: any, + runId: string, + // @langchain/core 1.1.x dispatches parentRunId here despite its stale .d.ts. + parentRunId?: string, + _tags?: string[], + _metadata?: Record, + _runType?: string, + runName?: string, + ): void { + this.startRun({ + runId, + parentRunId, + name: runName || serializedName(chain) || "langchain.chain", + spanType: mlflow.SpanType.CHAIN, + inputs, + }); + } - } catch (error: any) { - // 409 means location already exists, which is fine - if (error?.message?.includes("409")) { - return await this.linkExperimentToLocation(catalogName, schemaName, tableName); - } - console.warn(`⚠️ Error setting up trace location:`, error); - return null; - } + handleChainEnd(outputs: any, runId: string): void { + this.endRun(runId, outputs); } - /** - * Build headers for trace export using SDK authentication - * Includes required headers for Databricks OTel collector - */ - private async buildHeadersWithToken(): Promise> { - const headers: Record = {}; + handleChainError(error: any, runId: string): void { + this.errorRun(runId, error); + } - // Get authentication headers from SDK - if (this.databricksClient) { - const authHeaders = new Headers(); - await this.databricksClient.config.authenticate(authHeaders); + handleToolStart( + tool: any, + input: string, + runId: string, + parentRunId?: string, + _tags?: string[], + _metadata?: Record, + runName?: string, + ): void { + this.startRun({ + runId, + parentRunId, + name: runName || serializedName(tool) || "langchain.tool", + spanType: mlflow.SpanType.TOOL, + inputs: parseToolInput(input), + }); + } - // Convert Headers to plain object - authHeaders.forEach((value, key) => { - headers[key] = value; - }); - } else if (this.config.mlflowTrackingUri === "databricks") { - console.warn( - "⚠️ No Databricks client available for trace export. Traces may not be exported." - ); - } + handleToolEnd(output: any, runId: string): void { + this.endRun(runId, output); + } - // Required for Databricks OTel collector - if (this.config.mlflowTrackingUri === "databricks") { - headers["content-type"] = "application/x-protobuf"; - - // Unity Catalog table name for trace storage - const ucTableName = process.env.OTEL_UC_TABLE_NAME; - if (ucTableName) { - headers["X-Databricks-UC-Table-Name"] = ucTableName; - console.log(`📊 Traces will be stored in UC table: ${ucTableName}`); - } else { - console.warn( - "⚠️ OTEL_UC_TABLE_NAME not set. You need to:\n" + - " 1. Enable OTel collector preview in your workspace\n" + - " 2. Create UC tables for trace storage\n" + - " 3. Set OTEL_UC_TABLE_NAME=.._otel_spans" - ); - } - } + handleToolError(error: any, runId: string): void { + this.errorRun(runId, error); + } - // Add experiment ID if provided - if (this.config.experimentId) { - headers["x-mlflow-experiment-id"] = this.config.experimentId; - } + handleRetrieverStart( + retriever: any, + query: string, + runId: string, + parentRunId?: string, + _tags?: string[], + _metadata?: Record, + name?: string, + ): void { + this.startRun({ + runId, + parentRunId, + name: name || serializedName(retriever) || "langchain.retriever", + spanType: mlflow.SpanType.RETRIEVER, + inputs: { query }, + }); + } - // Add run ID if provided - if (this.config.runId) { - headers["x-mlflow-run-id"] = this.config.runId; - } + handleRetrieverEnd(documents: any, runId: string): void { + this.endRun(runId, documents); + } + + handleRetrieverError(error: any, runId: string): void { + this.errorRun(runId, error); + } - return headers; + handleAgentAction(action: any, runId: string): void { + this.recordDecision("langchain.agent.action", action, runId); } - /** - * Initialize tracing - registers the tracer provider and instruments LangChain - */ - async initialize(): Promise { - if (this.isInitialized) { - console.warn("MLflow tracing already initialized"); - return; + handleAgentEnd(finish: any, runId: string): void { + this.recordDecision("langchain.agent.end", finish, runId); + } + + handleChatModelStart( + llm: any, + messages: any, + runId: string, + parentRunId?: string, + extraParams?: Record, + _tags?: string[], + metadata?: Record, + runName?: string, + ): void { + const params = extraParams?.invocation_params ?? extraParams ?? {}; + const model = + params.model ?? params.model_name ?? runName ?? serializedName(llm); + const provider = + metadata?.ls_provider ?? + params.provider ?? + providerFromType(params._type); + this.startRun({ + runId, + parentRunId, + name: runName || model || "langchain.chat_model", + spanType: mlflow.SpanType.CHAT_MODEL, + inputs: messages, + model, + provider, + }); + } + + handleLLMStart( + llm: any, + prompts: string[], + runId: string, + parentRunId?: string, + extraParams?: Record, + _tags?: string[], + metadata?: Record, + runName?: string, + ): void { + if (this.runs.has(runId)) return; + const params = extraParams?.invocation_params ?? extraParams ?? {}; + const model = + params.model ?? params.model_name ?? runName ?? serializedName(llm); + this.startRun({ + runId, + parentRunId, + name: runName || model || "langchain.llm", + spanType: mlflow.SpanType.LLM, + inputs: prompts, + model, + provider: + metadata?.ls_provider ?? + params.provider ?? + providerFromType(params._type), + }); + } + + handleLLMNewToken(_token: string, _indices: unknown, runId: string): void { + const run = this.runs.get(runId); + if (run && run.firstTokenNs === undefined) + run.firstTokenNs = process.hrtime.bigint(); + } + + handleLLMEnd(output: any, runId: string): void { + const run = this.runs.get(runId); + if (!run) return; + const endedNs = process.hrtime.bigint(); + const message = firstGenerationMessage(output); + const usageMetadata = asRecord(message?.usage_metadata); + const responseMetadata = asRecord(message?.response_metadata); + const llmOutput = asRecord(output?.llmOutput ?? output?.llm_output); + const legacyUsage = asRecord( + llmOutput.tokenUsage ?? llmOutput.token_usage ?? llmOutput.usage, + ); + const inputTokens = firstPresent( + [usageMetadata, legacyUsage], + ["input_tokens", "prompt_tokens", "inputTokens", "promptTokens"], + ); + const outputTokens = firstPresent( + [usageMetadata, legacyUsage], + [ + "output_tokens", + "completion_tokens", + "outputTokens", + "completionTokens", + ], + ); + const inputDetails = asRecord(usageMetadata.input_token_details); + const usage: NormalizedUsage = { + inputTokens: nonnegativeInt(inputTokens), + outputTokens: nonnegativeInt(outputTokens), + totalTokens: nonnegativeInt( + firstPresent( + [usageMetadata, legacyUsage], + ["total_tokens", "totalTokens"], + ) ?? nonnegativeInt(inputTokens) + nonnegativeInt(outputTokens), + ), + costAvailable: false, + }; + const cacheRead = firstPresent( + [inputDetails, usageMetadata, legacyUsage], + [ + "cache_read", + "cache_read_input_tokens", + "cached_tokens", + "cacheReadInputTokens", + ], + ); + const cacheCreation = firstPresent( + [inputDetails, usageMetadata, legacyUsage], + [ + "cache_creation", + "cache_creation_input_tokens", + "cacheCreationInputTokens", + ], + ); + if (cacheRead !== undefined) + usage.cacheReadInputTokens = nonnegativeInt(cacheRead); + if (cacheCreation !== undefined) { + usage.cacheCreationInputTokens = nonnegativeInt(cacheCreation); + } + const cost = providerCost(responseMetadata, llmOutput, usageMetadata); + if (cost !== undefined) { + usage.costAvailable = true; + usage.costUsd = cost; + } + this.requestTrace?.addModelUsage(usage); + + const firstTokenNs = run.firstTokenNs ?? endedNs; + const model = + run.model ?? responseMetadata.model_name ?? llmOutput.model_name; + run.span.setAttributes({ + "appkit.model": safeTraceValue(model), + "appkit.provider": safeTraceValue(run.provider), + "appkit.usage": usage, + "appkit.ttft_ms": nsToMs(firstTokenNs - run.startedNs), + "appkit.stream_duration_ms": nsToMs(endedNs - run.startedNs), + "appkit.finish_reason": safeTraceValue( + responseMetadata.finish_reason ?? + firstGenerationInfo(output).finish_reason, + ), + "appkit.cost_available": usage.costAvailable, + "mlflow.chat.tokenUsage": toMlflowTokenUsage(usage), + }); + if (usage.costAvailable) { + run.span.setAttribute("appkit.cost_usd", usage.costUsd); + run.span.setAttribute("mlflow.llm.cost", usage.costUsd); } + this.endRun(runId, output); + } - // No-op mode: skip all tracing setup (used in tests) - if (this.config.mlflowTrackingUri === "noop") { - this.isInitialized = true; - console.log("⏭️ MLflow tracing disabled (MLFLOW_TRACKING_URI=noop)"); - return; + handleLLMError(error: any, runId: string): void { + const usage: NormalizedUsage = { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + costAvailable: false, + }; + this.requestTrace?.addModelUsage(usage); + const run = this.runs.get(runId); + if (run) { + const endedNs = process.hrtime.bigint(); + run.span.setAttributes({ + "appkit.model": safeTraceValue(run.model), + "appkit.provider": safeTraceValue(run.provider), + "appkit.usage": usage, + "appkit.ttft_ms": nsToMs((run.firstTokenNs ?? endedNs) - run.startedNs), + "appkit.stream_duration_ms": nsToMs(endedNs - run.startedNs), + "appkit.cost_available": false, + }); } + this.errorRun(runId, error); + } - // Initialize Databricks SDK client for authentication - if (this.config.mlflowTrackingUri === "databricks") { - console.log("🔐 Initializing Databricks SDK authentication..."); + private startRun(options: { + runId: string; + parentRunId?: string; + name: string; + spanType: mlflow.SpanType; + inputs: unknown; + model?: string; + provider?: string; + }): void { + const parent = + (options.parentRunId + ? this.runs.get(options.parentRunId)?.span + : undefined) ?? + this.root ?? + undefined; + const span = mlflow.startSpan({ + name: options.name, + spanType: options.spanType, + parent, + inputs: safeTraceValue(options.inputs), + attributes: { + "langchain.run_id": safeTraceValue(options.runId), + "langchain.parent_run_id": safeTraceValue(options.parentRunId), + }, + }); + this.runs.set(options.runId, { + span, + startedNs: process.hrtime.bigint(), + model: options.model, + provider: options.provider, + }); + } + private recordDecision(name: string, value: unknown, runId: string): void { + const parent = this.runs.get(runId)?.span; + if (!parent) return; + const captured = safeTraceValue(value); + const span = mlflow.startSpan({ + name, + spanType: mlflow.SpanType.CHAIN, + parent, + inputs: captured, + attributes: { "langchain.run_id": safeTraceValue(runId) }, + }); + span.end({ + outputs: captured, + status: mlflow.SpanStatusCode.OK, + }); + } + + private endRun(runId: string, outputs: unknown): void { + const run = this.runs.get(runId); + if (!run) return; + this.runs.delete(runId); + run.span.end({ + outputs: safeTraceValue(outputs), + status: mlflow.SpanStatusCode.OK, + }); + } + + private errorRun(runId: string, error: unknown): void { + const run = this.runs.get(runId); + if (!run) return; + this.runs.delete(runId); + const normalized = safeError(error); + run.span.recordException(new Error(normalized)); + run.span.end({ + outputs: { + partial_output: { available: false, reason: "no output produced" }, + error: normalized, + }, + status: mlflow.SpanStatusCode.ERROR, + }); + } +} + +export function createLangChainTracingCallback(): LangChainTracingCallback { + return new LangChainTracingCallback(); +} + +export async function withAgentRequestTrace( + inputs: unknown, + identity: TraceIdentity, + operation: (trace: AgentRequestTrace) => Promise, +): Promise<{ value: T; traceId: string }> { + return (await mlflow.withSpan( + async (span) => { + span.setInputs(safeTraceValue(inputs)); + setTraceIdentity(identity.sessionId, identity.userId, identity.requestId); + const requestTrace = new RequestTraceState(span); + requestTraces.set(span.traceId, requestTrace); + let recordedError = false; + + const trace: AgentRequestTrace = { + traceId: span.traceId, + setOutputs: (outputs) => span.setOutputs(safeTraceValue(outputs)), + recordError: (error) => { + recordedError = true; + const message = safeError(error); + span.setOutputs({ + partial_output: { available: false, reason: "no output produced" }, + error: message, + }); + span.setStatus(mlflow.SpanStatusCode.ERROR, message); + span.recordException(new Error(message)); + return message; + }, + }; try { - // Create WorkspaceClient - automatically handles auth chain: - // 1. Databricks Native (PAT, OAuth M2M, OAuth U2M) - // 2. Azure Native (Azure CLI, MSI, Client Secret) - // 3. GCP Native (GCP credentials, default application credentials) - // 4. Databricks CLI profile - this.databricksClient = new WorkspaceClient({ - profile: process.env.DATABRICKS_CONFIG_PROFILE, - host: process.env.DATABRICKS_HOST, - token: process.env.DATABRICKS_TOKEN, - clientId: process.env.DATABRICKS_CLIENT_ID, - clientSecret: process.env.DATABRICKS_CLIENT_SECRET, + const value = await operation(trace); + if (!recordedError) span.setStatus(mlflow.SpanStatusCode.OK); + return { value, traceId: span.traceId }; + } catch (error) { + const details = safeLogError(error); + const message = details.message; + span.setOutputs({ + partial_output: { available: false, reason: "no output produced" }, + error: message, }); + span.setStatus(mlflow.SpanStatusCode.ERROR, message); + throw safeErrorForThrow(details); + } finally { + requestTrace.finalize(); + requestTraces.delete(span.traceId); + } + }, + { + name: "langchain.request", + spanType: mlflow.SpanType.AGENT, + }, + )) as { value: T; traceId: string }; +} + +export async function flushTracing(): Promise { + try { + await mlflow.flushTraces(); + } catch (error) { + console.error( + "MLflow trace export failed during flush:", + safeLogError(error), + ); + } +} + +function serializedName(value: any): string | undefined { + const id = Array.isArray(value?.id) ? value.id : []; + const name = id[id.length - 1]; + return typeof name === "string" ? name : undefined; +} - // Verify authentication works by getting config - await this.databricksClient.config.ensureResolved(); - console.log("✅ Databricks SDK authentication successful"); - - // Set up experiment trace location in UC (if not already configured) - if (!process.env.OTEL_UC_TABLE_NAME) { - const tableName = await this.setupExperimentTraceLocation(); - if (tableName) { - // Set environment variable so buildHeadersWithToken() can use it - process.env.OTEL_UC_TABLE_NAME = tableName; - } +function providerFromType(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + return value.replace(/^chat-/, "").replace(/-chat$/, ""); +} + +function parseToolInput(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function redactSecretText(value: string): string { + return value.replace( + SECRET_TEXT, + (_match, prefix: string, quote?: string) => + `${prefix}${quote ?? ""}[REDACTED]${quote ?? ""}`, + ); +} + +function toJsonable(value: unknown, seen: WeakSet): unknown { + try { + if ( + value === null || + typeof value === "boolean" || + typeof value === "number" + ) { + return value; + } + if (typeof value === "bigint") return value.toString(); + if (typeof value === "string") { + const trimmed = value.trim(); + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + try { + return toJsonable(JSON.parse(value), seen); + } catch { + // Preserve malformed JSON as redacted text. } - } catch (error) { - console.warn("⚠️ Failed to initialize Databricks SDK authentication:", error); - console.warn("⚠️ Traces may not be exported without authentication"); } + return redactSecretText(value); } + if (typeof value === "undefined") return null; + if (typeof value === "function" || typeof value === "symbol") + return String(value); + if (value instanceof Error) { + return { name: value.name, message: redactSecretText(value.message) }; + } + if (Buffer.isBuffer(value)) return redactSecretText(value.toString("utf8")); + if (Array.isArray(value)) + return value.map((item) => toJsonable(item, seen)); + if (value instanceof Date) return value.toISOString(); + if (value instanceof Map) { + return toJsonable(Object.fromEntries(value), seen); + } + if (value instanceof Set) + return [...value].map((item) => toJsonable(item, seen)); + if (typeof value === "object") { + if (seen.has(value)) return ""; + seen.add(value); + const objectValue = value as Record; + const result: Record = {}; + for (const key of Object.keys(objectValue).sort()) { + result[key] = SECRET_KEY.test(key) + ? "[REDACTED]" + : toJsonable(objectValue[key], seen); + } + seen.delete(value); + return result; + } + return redactSecretText(String(value)); + } catch (error) { + return `<${typeof value}: ${error instanceof Error ? error.name : "Error"}>`; + } +} - // Build headers with SDK authentication - const headers = await this.buildHeadersWithToken(); - - // Construct trace endpoint URL - const traceUrl = this.buildTraceUrl(); - - // Log detailed export configuration for debugging - console.log("🔍 OTel Export Configuration:"); - console.log(" URL:", traceUrl); - console.log(" Headers:", Object.keys(headers).join(", ")); - // Check for both lowercase and capitalized Authorization header - const hasAuth = headers["Authorization"] || headers["authorization"]; - console.log(" Auth:", hasAuth ? "Present (Bearer token)" : "Missing"); - console.log(" Content-Type:", headers["content-type"]); - console.log(" UC Table:", headers["X-Databricks-UC-Table-Name"] || "Not set"); - console.log(" Experiment ID:", headers["x-mlflow-experiment-id"] || "Not set"); - - // Create OTLP exporter with headers - this.exporter = new OTLPTraceExporter({ - url: traceUrl, - headers, - timeoutMillis: 30000, - }); +export function safeTraceValue( + value: unknown, + maxBytes = MAX_CAPTURE_BYTES, +): unknown { + const redacted = toJsonable(value, new WeakSet()); + const encoded = Buffer.from(JSON.stringify(redacted), "utf8"); + if (encoded.byteLength <= maxBytes) return redacted; + + let previewBytes = encoded.subarray(0, maxBytes); + let preview = previewBytes.toString("utf8"); + while (preview.endsWith("�") && previewBytes.length > 0) { + previewBytes = previewBytes.subarray(0, previewBytes.length - 1); + preview = previewBytes.toString("utf8"); + } + return { + truncated: true, + originalBytes: encoded.byteLength, + sha256: createHash("sha256").update(encoded).digest("hex"), + preview, + }; +} - // Add span processor with error handling - const processor = this.config.useBatchProcessor - ? new BatchSpanProcessor(this.exporter) - : new SimpleSpanProcessor(this.exporter); +export class BoundedTraceAccumulator { + private readonly digest = createHash("sha256"); + private readonly previewChunks: Buffer[] = []; + private previewBytes = 0; + private originalBytes = 1; + private itemCount = 0; + private items: unknown[] | null = []; + private finalized?: unknown[] | Record; + + constructor(private readonly maxBytes = MAX_CAPTURE_BYTES) { + if (!Number.isInteger(maxBytes) || maxBytes < 1) { + throw new Error("maxBytes must be a positive integer"); + } + const opening = Buffer.from("["); + this.digest.update(opening); + this.previewChunks.push(opening); + this.previewBytes = opening.byteLength; + } - this.provider.addSpanProcessor(processor); + add(value: unknown): void { + if (this.finalized !== undefined) { + throw new Error("cannot add values after capture is finalized"); + } + const redacted = safeTraceValue(value); + const encoded = Buffer.from(JSON.stringify(redacted), "utf8"); + const prefix = this.itemCount === 0 ? Buffer.alloc(0) : Buffer.from(","); + const chunk = Buffer.concat([prefix, encoded]); + this.digest.update(chunk); + this.originalBytes += chunk.byteLength; + + const remaining = this.maxBytes - this.previewBytes; + if (remaining > 0) { + const retained = chunk.subarray(0, remaining); + this.previewChunks.push(retained); + this.previewBytes += retained.byteLength; + } + if (this.items !== null) { + if (this.originalBytes + 1 <= this.maxBytes) this.items.push(redacted); + else this.items = null; + } + this.itemCount += 1; + } - // Register the tracer provider globally - this.provider.register(); + snapshot(): unknown[] | Record { + if (this.finalized !== undefined) return this.finalized; + const closing = Buffer.from("]"); + this.digest.update(closing); + this.originalBytes += closing.byteLength; + if (this.previewBytes < this.maxBytes) { + this.previewChunks.push(closing); + this.previewBytes += closing.byteLength; + } + if (this.items !== null && this.originalBytes <= this.maxBytes) { + this.finalized = this.items; + return this.finalized; + } + let previewBuffer = Buffer.concat(this.previewChunks); + let preview = previewBuffer.toString("utf8"); + while (preview.endsWith("�") && previewBuffer.length > 0) { + previewBuffer = previewBuffer.subarray(0, previewBuffer.length - 1); + preview = previewBuffer.toString("utf8"); + } + this.finalized = { + truncated: true, + originalBytes: this.originalBytes, + sha256: this.digest.digest("hex"), + preview, + }; + return this.finalized; + } +} - // Instrument LangChain callbacks to emit traces - new LangChainInstrumentation().manuallyInstrument(CallbackManagerModule); +function safeIdentity(value: string): string { + const safe = safeTraceValue(value, 2048); + return typeof safe === "string" ? safe : JSON.stringify(safe); +} - this.isInitialized = true; +export interface SafeLogError { + name: string; + message: string; + code?: string | number; +} - console.log("✅ MLflow tracing initialized", { - serviceName: this.config.serviceName, - experimentId: this.config.experimentId, - trackingUri: this.config.mlflowTrackingUri, - hasAuthClient: !!this.databricksClient, - }); +export function safeLogError(error: unknown): SafeLogError { + let name = "Error"; + let message: unknown = "Unknown error"; + let code: unknown; + + try { + if (error && typeof error === "object") { + const value = error as Record; + const constructorName = value.constructor?.name; + const declaredName = value.name; + name = + typeof constructorName === "string" && constructorName !== "Error" + ? constructorName + : typeof declaredName === "string" && declaredName + ? declaredName + : "Error"; + message = typeof value.message === "string" ? value.message : message; + code = value.code; + } else if (typeof error === "string") { + message = error; + } else if (error !== undefined && error !== null) { + message = String(error); + } + } catch { + // Do not inspect arbitrary error properties beyond the safe fallback. } - /** - * Shutdown tracing gracefully - flushes pending spans - */ - async shutdown(): Promise { - if (!this.isInitialized) { - return; + const safeName = safeTraceValue(name, 256); + const safeMessage = safeTraceValue(message, 2048); + const details: SafeLogError = { + name: typeof safeName === "string" ? safeName : "Error", + message: + typeof safeMessage === "string" + ? safeMessage + : JSON.stringify(safeMessage), + }; + if (typeof code === "string" || typeof code === "number") { + const safeCode = safeTraceValue(code, 256); + if (typeof safeCode === "string" || typeof safeCode === "number") { + details.code = safeCode; } + } + return details; +} - try { - await this.provider.shutdown(); - console.log("✅ MLflow tracing shutdown complete"); - } catch (error) { - console.error("Error shutting down tracing:", error); - throw error; - } +function safeErrorForThrow(details: SafeLogError): Error { + const error = new Error(details.message); + error.name = details.name; + if (details.code !== undefined) { + Object.defineProperty(error, "code", { + configurable: true, + enumerable: true, + value: details.code, + }); } + return error; +} + +export function sanitizePublicError(error: unknown): Error { + return safeErrorForThrow(safeLogError(error)); +} + +function safeError(error: unknown): string { + return safeLogError(error).message; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" + ? (value as Record) + : {}; +} + +function firstGenerationMessage(output: any): any { + const generation = output?.generations?.[0]?.[0]; + return generation?.message ?? generation; +} - /** - * Force flush pending spans (useful before process exit) - */ - async flush(): Promise { - if (!this.isInitialized) { - return; +function firstGenerationInfo(output: any): Record { + const generation = output?.generations?.[0]?.[0]; + return asRecord(generation?.generationInfo ?? generation?.generation_info); +} + +function firstPresent( + mappings: Record[], + keys: string[], +): unknown { + for (const mapping of mappings) { + for (const key of keys) { + if (mapping[key] !== undefined && mapping[key] !== null) + return mapping[key]; } + } + return undefined; +} - try { - await this.provider.forceFlush(); - } catch (error) { - console.error("Error flushing traces:", error); - throw error; +function nonnegativeInt(value: unknown): number { + if (typeof value === "boolean") return 0; + const number = Number(value ?? 0); + if (!Number.isFinite(number)) return 0; + return Math.max(0, Math.trunc(number)); +} + +function providerCost(...mappings: Record[]): number | undefined { + for (const mapping of mappings) { + for (const key of ["cost", "cost_usd", "total_cost_usd"]) { + const value = mapping[key]; + if (typeof value === "number" && Number.isFinite(value) && value >= 0) + return value; } } + return undefined; } -/** - * Initialize MLflow tracing with default configuration - * Call this once at application startup - */ -export async function initializeMLflowTracing(config?: TracingConfig): Promise { - const tracing = new MLflowTracing(config); - await tracing.initialize(); - return tracing; +function toMlflowTokenUsage(usage: NormalizedUsage): Record { + const result: Record = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + total_tokens: usage.totalTokens, + }; + if (usage.cacheReadInputTokens !== undefined) { + result.cache_read_input_tokens = usage.cacheReadInputTokens; + } + if (usage.cacheCreationInputTokens !== undefined) { + result.cache_creation_input_tokens = usage.cacheCreationInputTokens; + } + return result; } +function nsToMs(value: bigint): number { + return Math.max(0, Number(value) / 1_000_000); +} diff --git a/agent-langchain-ts/src/main.ts b/agent-langchain-ts/src/main.ts index d52483aa..32187ca3 100644 --- a/agent-langchain-ts/src/main.ts +++ b/agent-langchain-ts/src/main.ts @@ -4,6 +4,7 @@ config(); import { createAgent } from "./agent.js"; import { getMCPServers } from "./mcp-servers.js"; import { startServer } from "./framework/server.js"; +import { safeLogError } from "./framework/tracing.js"; const agent = await createAgent({ model: process.env.DATABRICKS_MODEL || "databricks-claude-sonnet-4-5", @@ -14,6 +15,6 @@ const agent = await createAgent({ }); startServer(agent).catch((error) => { - console.error("❌ Failed to start server:", error); + console.error("❌ Failed to start server:", safeLogError(error)); process.exit(1); }); diff --git a/agent-langchain-ts/src/tools.ts b/agent-langchain-ts/src/tools.ts index a17d88a6..b6013fab 100644 --- a/agent-langchain-ts/src/tools.ts +++ b/agent-langchain-ts/src/tools.ts @@ -25,6 +25,7 @@ import { buildMCPServerConfig, } from "@databricks/langchainjs"; import { MultiServerMCPClient } from "@langchain/mcp-adapters"; +import { safeLogError } from "./framework/tracing.js"; /** * Example: Weather lookup tool @@ -47,7 +48,7 @@ export const weatherTool = tool( .string() .describe("The city and state, e.g. 'San Francisco, CA'"), }), - } + }, ); /** @@ -73,7 +74,7 @@ export const calculatorTool = tool( .string() .describe("Mathematical expression to evaluate, e.g. '2 + 2 * 3'"), }), - } + }, ); /** @@ -94,10 +95,10 @@ export const timeTool = tool( .string() .optional() .describe( - "IANA timezone name, e.g. 'America/New_York', 'Europe/London', defaults to UTC" + "IANA timezone name, e.g. 'America/New_York', 'Europe/London', defaults to UTC", ), }), - } + }, ); /** @@ -151,13 +152,12 @@ export async function getMCPTools(servers: DatabricksMCPServer[]) { const tools = await globalMCPClient.getTools(); console.log( - `✅ Loaded ${tools.length} MCP tools from ${servers.length} server(s)` + `✅ Loaded ${tools.length} MCP tools from ${servers.length} server(s)`, ); return tools; } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error("Error loading MCP tools:", message); + console.error("Error loading MCP tools:", safeLogError(error)); throw error; } } @@ -185,8 +185,10 @@ export async function getAllTools(mcpServers?: DatabricksMCPServer[]) { const mcpTools = await getMCPTools(mcpServers); return [...basicTools, ...mcpTools]; } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error("Failed to load MCP tools, using basic tools only:", message); + console.error( + "Failed to load MCP tools, using basic tools only:", + safeLogError(error), + ); return basicTools; } } diff --git a/agent-langchain-ts/tests/e2e/deployed.test.ts b/agent-langchain-ts/tests/e2e/deployed.test.ts index 1c6fee03..729ff34f 100644 --- a/agent-langchain-ts/tests/e2e/deployed.test.ts +++ b/agent-langchain-ts/tests/e2e/deployed.test.ts @@ -9,13 +9,13 @@ * Run with: APP_URL= npm run test:deployed */ -import { describe, test, expect, beforeAll } from '@jest/globals'; +import { describe, test, expect, beforeAll } from "@jest/globals"; +import { WorkspaceClient } from "@databricks/sdk-experimental"; +import { createAuthProvider, MlflowClient, type Trace } from "@mlflow/core"; import { getDeployedAuthToken, parseSSEStream } from "../helpers.js"; -if (!process.env.APP_URL) { - throw new Error("APP_URL environment variable is required to run deployed e2e tests"); -} const APP_URL = process.env.APP_URL; +const deployedDescribe = APP_URL ? describe : describe.skip; let authToken: string; beforeAll(async () => { @@ -23,10 +23,84 @@ beforeAll(async () => { authToken = await getDeployedAuthToken(); }, 30000); -describe("Deployed App Tests", () => { +async function retrieveTrace(traceId: string): Promise { + const profile = process.env.DATABRICKS_CLI_PROFILE; + const trackingUri = profile ? `databricks://${profile}` : "databricks"; + const client = new MlflowClient({ + trackingUri, + authProvider: createAuthProvider({ trackingUri }), + }); + let lastError: unknown; + for (let attempt = 0; attempt < 15; attempt += 1) { + try { + return await client.getTrace(traceId); + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + } + throw lastError; +} + +async function queryOtelSpans(traceId: string): Promise { + const warehouseId = process.env.MLFLOW_TRACING_SQL_WAREHOUSE_ID; + const otelSpansTable = process.env.MLFLOW_OTEL_SPANS_TABLE; + if (!warehouseId || !otelSpansTable) { + throw new Error( + "MLFLOW_TRACING_SQL_WAREHOUSE_ID and MLFLOW_OTEL_SPANS_TABLE are required", + ); + } + + const profile = process.env.DATABRICKS_CLI_PROFILE; + const client = new WorkspaceClient({ profile }); + const storedTraceId = traceId.slice(traceId.lastIndexOf("/") + 1).toLowerCase(); + let lastState: string | undefined; + + for (let attempt = 0; attempt < 15; attempt += 1) { + let statement = await client.statementExecution.executeStatement({ + warehouse_id: warehouseId, + statement: + "SELECT trace_id, span_id FROM IDENTIFIER(:otel_spans_table) " + + "WHERE trace_id = :trace_id ORDER BY start_time_unix_nano", + parameters: [ + { name: "otel_spans_table", type: "STRING", value: otelSpansTable }, + { name: "trace_id", type: "STRING", value: storedTraceId }, + ], + wait_timeout: "10s", + on_wait_timeout: "CONTINUE", + }); + + for (let poll = 0; poll < 12; poll += 1) { + lastState = statement.status?.state; + if (lastState !== "PENDING" && lastState !== "RUNNING") break; + if (!statement.statement_id) { + throw new Error("SQL statement is pending without a statement ID"); + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + statement = await client.statementExecution.getStatement({ + statement_id: statement.statement_id, + }); + } + + if (statement.status?.state === "FAILED") { + throw new Error(`UC trace query failed: ${statement.status.error?.message}`); + } + const rows = statement.result?.data_array ?? []; + if (rows.some((row) => row[0]?.toLowerCase() === storedTraceId)) { + return rows as string[][]; + } + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + + throw new Error( + `UC spans table ${otelSpansTable} has no rows for returned trace ${traceId}; last SQL state: ${lastState}`, + ); +} + +deployedDescribe("Deployed App Tests", () => { describe("/invocations endpoint", () => { test("should respond with text", async () => { - const response = await fetch(`${APP_URL}/invocations`, { + const response = await fetch(`${APP_URL!}/invocations`, { method: "POST", headers: { Authorization: `Bearer ${authToken}`, @@ -39,11 +113,22 @@ describe("Deployed App Tests", () => { }); expect(response.ok).toBe(true); + const traceId = response.headers.get("x-mlflow-trace-id"); + expect(traceId).toMatch(/^trace:\/[^/]+\/[0-9a-f]{32}$/); const text = await response.text(); const { fullOutput } = parseSSEStream(text); expect(fullOutput.length).toBeGreaterThan(0); expect(text).toContain("data: [DONE]"); - }, 30000); + const trace = await retrieveTrace(traceId!); + expect(trace.info.traceId).toBe(traceId); + const roots = trace.data.spans.filter((span) => span.parentId === null); + expect(roots).toHaveLength(1); + expect(roots[0].spanType).toBe("AGENT"); + expect(roots[0].inputs).toBeDefined(); + expect(roots[0].outputs).toBeDefined(); + const persistedRows = await queryOtelSpans(traceId!); + expect(persistedRows.length).toBeGreaterThan(0); + }, 180000); }); }); diff --git a/agent-langchain-ts/tests/framework/endpoints.test.ts b/agent-langchain-ts/tests/framework/endpoints.test.ts index 23de542b..3bf66a0c 100644 --- a/agent-langchain-ts/tests/framework/endpoints.test.ts +++ b/agent-langchain-ts/tests/framework/endpoints.test.ts @@ -3,44 +3,305 @@ * Tests both /invocations (Responses API) and /api/chat (AI SDK + useChat) */ -import { describe, test, expect, beforeAll, afterAll } from "@jest/globals"; -import { spawn } from "child_process"; -import type { ChildProcess } from "child_process"; +import { + describe, + test, + expect, + beforeAll, + afterAll, + afterEach, + jest, +} from "@jest/globals"; + +import http, { type Server } from "http"; +import express from "express"; import OpenAI from "openai"; +import { format } from "util"; +import type { AgentInterface } from "../../src/framework/agent-interface.js"; +import { createInvocationsRouter } from "../../src/framework/routes/invocations.js"; +import { StubAgent } from "./stub-agent.js"; +import { + flushTracing, + initializeTracing, +} from "../../src/framework/tracing.js"; describe("API Endpoints", () => { - let agentProcess: ChildProcess; - const PORT = 5555; // Use different port to avoid conflicts - const BASE_URL = `http://localhost:${PORT}`; + let server: Server; + let exporterServer: Server; + let baseUrl: string; let client: OpenAI; + const allowedOrigins = new Set(); + const externalRequests: string[] = []; + const nativeFetch = globalThis.fetch; + let restoreFetchGuard: (() => void) | undefined; + const exportedInfoUrls: string[] = []; beforeAll(async () => { - // Start framework server with stub agent (no LLM required) - agentProcess = spawn("node_modules/.bin/tsx", ["tests/framework/stub-server.ts"], { - env: { ...process.env, PORT: PORT.toString(), MLFLOW_TRACKING_URI: "noop" }, - stdio: ["ignore", "pipe", "pipe"], + exporterServer = http.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + request.on("end", () => { + if (request.url?.endsWith("/info")) { + exportedInfoUrls.push(request.url); + } + const body = Buffer.concat(chunks); + const json = request.url?.endsWith("/info") + ? JSON.parse(body.toString("utf8")) + : {}; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(json)); + }); }); + await new Promise((resolve) => { + exporterServer.listen(0, "127.0.0.1", resolve); + }); + const exporterAddress = exporterServer.address(); + if (!exporterAddress || typeof exporterAddress === "string") { + throw new Error("test MLflow exporter did not bind to a TCP port"); + } + const exporterOrigin = `http://127.0.0.1:${exporterAddress.port}`; + allowedOrigins.add(exporterOrigin); + process.env.MLFLOW_TRACKING_URI = exporterOrigin; + process.env.MLFLOW_EXPERIMENT_ID = "123456789"; + process.env.MLFLOW_UC_CATALOG = "catalog_test"; + process.env.MLFLOW_UC_SCHEMA = "schema_test"; + process.env.MLFLOW_UC_TABLE_PREFIX = "langchain_test"; + initializeTracing(); - // Poll /health until server is ready (max 20s) - const start = Date.now(); - while (Date.now() - start < 20000) { - try { - const r = await fetch(`${BASE_URL}/health`); - if (r.ok) break; - } catch {} - await new Promise((r) => setTimeout(r, 200)); + const app = express(); + app.use(express.json()); + const router = createInvocationsRouter(new StubAgent()); + app.use("/invocations", router); + app.use("/responses", router); + server = await new Promise((resolve) => { + const listener = app.listen(0, () => resolve(listener)); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("test server did not bind to a TCP port"); } + baseUrl = `http://127.0.0.1:${address.port}`; + allowedOrigins.add(baseUrl); - client = new OpenAI({ baseURL: BASE_URL, apiKey: "not-needed" }); + const fetchGuard = jest + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + const url = new URL( + input instanceof Request ? input.url : input.toString(), + ); + if (!allowedOrigins.has(url.origin)) { + externalRequests.push(url.toString()); + throw new Error(`Unit test attempted external request: ${url}`); + } + return nativeFetch(input, init); + }); + restoreFetchGuard = () => fetchGuard.mockRestore(); + + client = new OpenAI({ baseURL: baseUrl, apiKey: "not-needed" }); }, 30000); + afterEach(async () => { + await flushTracing(); + const attempted = externalRequests.splice(0); + expect(attempted).toEqual([]); + exportedInfoUrls.length = 0; + }); + afterAll(async () => { - if (agentProcess) { - agentProcess.kill(); - } + await flushTracing(); + restoreFetchGuard?.(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await new Promise((resolve, reject) => { + exporterServer.close((error) => (error ? reject(error) : resolve())); + }); }); describe("/invocations endpoint", () => { + test("returns the V4 MLflow trace ID for a streaming request", async () => { + const response = await fetch(`${baseUrl}/invocations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Session-Id": "session-stream", + "X-User-Id": "user-stream", + "X-Request-Id": "request-stream", + }, + body: JSON.stringify({ + input: [{ role: "user", content: "trace this stream" }], + stream: true, + }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("x-mlflow-trace-id")).toMatch( + /^trace:\/catalog_test\.schema_test\.langchain_test\/[0-9a-f]{32}$/, + ); + await response.text(); + await flushTracing(); + const traceId = response.headers.get("x-mlflow-trace-id")!; + expect(exportedInfoUrls).toContain( + `/api/4.0/mlflow/traces/catalog_test.schema_test.langchain_test/${traceId.split("/").pop()}/info`, + ); + }); + + test("returns the same V4 MLflow trace ID for a non-streaming request", async () => { + const response = await fetch(`${baseUrl}/invocations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: [{ role: "user", content: "trace this response" }], + stream: false, + }), + }); + + expect(response.status).toBe(200); + const traceId = response.headers.get("x-mlflow-trace-id"); + expect(traceId).toMatch( + /^trace:\/catalog_test\.schema_test\.langchain_test\/[0-9a-f]{32}$/, + ); + const body = (await response.json()) as { trace_id?: string }; + expect(body.trace_id).toBe(traceId); + }); + + test("returns the V4 MLflow trace ID when a non-streaming invocation fails", async () => { + const failingAgent: AgentInterface = { + async invoke() { + throw new Error("expected invocation failure"); + }, + async *stream() { + throw new Error("stream should not be called"); + }, + }; + const app = express(); + app.use(express.json()); + app.use("/invocations", createInvocationsRouter(failingAgent)); + const server = await new Promise((resolve) => { + const listener = app.listen(0, () => resolve(listener)); + }); + const errorLog = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("test server did not bind to a TCP port"); + } + const origin = `http://127.0.0.1:${address.port}`; + allowedOrigins.add(origin); + const response = await fetch(`${origin}/invocations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: [{ role: "user", content: "fail with a trace" }], + stream: false, + }), + }); + + expect(response.status).toBe(500); + expect(response.headers.get("x-mlflow-trace-id")).toMatch( + /^trace:\/catalog_test\.schema_test\.langchain_test\/[0-9a-f]{32}$/, + ); + await flushTracing(); + expect(errorLog).toHaveBeenCalledWith( + "Agent invocation error:", + expect.objectContaining({ message: "expected invocation failure" }), + ); + } finally { + await flushTracing(); + errorLog.mockRestore(); + const address = server.address(); + if (address && typeof address !== "string") { + allowedOrigins.delete(`http://127.0.0.1:${address.port}`); + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + + test("logs SDK authentication failures without serializing credentials or environment", async () => { + const sentinelCredential = "test-only-sentinel-value"; + const environmentMarker = "TEST_FULL_ENV_MARKER"; + class ConfigError extends Error { + readonly code = "UNAUTHENTICATED"; + readonly config = { + env: { + DATABRICKS_TOKEN: sentinelCredential, + [environmentMarker]: "present-only-in-sdk-config", + }, + headers: { + authorization: `Bearer ${sentinelCredential}`, + cookie: `session=${sentinelCredential}`, + "x-api-key": sentinelCredential, + }, + }; + } + + const failingAgent: AgentInterface = { + async invoke() { + throw new ConfigError( + `authentication failed: Authorization: Bearer ${sentinelCredential}`, + ); + }, + async *stream() { + throw new Error("stream should not be called"); + }, + }; + const app = express(); + app.use(express.json()); + app.use("/invocations", createInvocationsRouter(failingAgent)); + const server = await new Promise((resolve) => { + const listener = app.listen(0, () => resolve(listener)); + }); + const logged: string[] = []; + const errorLog = jest + .spyOn(console, "error") + .mockImplementation((...args: unknown[]) => { + logged.push(format(...args)); + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("test server did not bind to a TCP port"); + } + const origin = `http://127.0.0.1:${address.port}`; + allowedOrigins.add(origin); + const response = await fetch(`${origin}/invocations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: [{ role: "user", content: "fail authentication" }], + stream: false, + }), + }); + + expect(response.status).toBe(500); + await flushTracing(); + const output = logged.join("\n"); + expect(output).toContain("ConfigError"); + expect(output).toContain("UNAUTHENTICATED"); + expect(output).toContain("authentication failed"); + expect(output).toContain("Authorization: [REDACTED]"); + expect(output).not.toContain(sentinelCredential); + expect(output).not.toContain(environmentMarker); + expect(output).not.toContain("present-only-in-sdk-config"); + } finally { + await flushTracing(); + errorLog.mockRestore(); + const address = server.address(); + if (address && typeof address !== "string") { + allowedOrigins.delete(`http://127.0.0.1:${address.port}`); + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + test("should respond with Responses API format", async () => { const stream = await client.responses.create({ model: "test-model", @@ -84,6 +345,5 @@ describe("API Endpoints", () => { expect(hasTextDelta).toBe(true); }, 30000); - }); }); diff --git a/agent-langchain-ts/tests/framework/public-error-boundary.test.ts b/agent-langchain-ts/tests/framework/public-error-boundary.test.ts new file mode 100644 index 00000000..72dda5d5 --- /dev/null +++ b/agent-langchain-ts/tests/framework/public-error-boundary.test.ts @@ -0,0 +1,111 @@ +import { Config, ConfigError } from "@databricks/sdk-experimental"; +import { describe, expect, jest, test } from "@jest/globals"; +import { createAgent, StandardAgent } from "../../src/agent.js"; + +function sdkAuthFailure() { + const sentinel = "sdk-auth-sentinel-value"; + const marker = "SDK_FULL_ENV_MARKER"; + const config = new Config({ + env: { + DATABRICKS_TOKEN: sentinel, + [marker]: "environment-must-not-serialize", + }, + }); + return { + sentinel, + marker, + error: new ConfigError( + `ChatDatabricks authentication failed: Authorization: Bearer ${sentinel}`, + config, + ), + }; +} + +describe("StandardAgent public error boundary", () => { + test.each(["invoke", "stream"] as const)( + "sanitizes a synchronous SDK auth failure from the %s boundary", + async (boundary) => { + const failure = sdkAuthFailure(); + const rawAgent = { + async invoke() { + throw failure.error; + }, + streamEvents() { + throw failure.error; + }, + }; + const agent = new StandardAgent(rawAgent as never, "system"); + + let caught: unknown; + try { + if (boundary === "invoke") { + await agent.invoke({ input: "hello" }); + } else { + for await (const _event of agent.stream({ input: "hello" })) { + // The auth failure occurs before the first event. + } + } + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + expect(caught).not.toBe(failure.error); + const serialized = JSON.stringify(caught); + expect(serialized).not.toContain(failure.sentinel); + expect(serialized).not.toContain(failure.marker); + expect(serialized).not.toContain("environment-must-not-serialize"); + expect(String(caught)).toContain("Authorization: [REDACTED]"); + }, + ); + + test.each(["invoke", "stream"] as const)( + "sanitizes a real ChatDatabricks authentication failure from %s", + async (boundary) => { + const sentinel = "real-chat-databricks-auth-sentinel"; + const marker = "REAL_CHAT_DATABRICKS_ENV_MARKER"; + const errorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + const agent = await createAgent({ + model: "databricks-claude-sonnet-4-5", + auth: { + env: { + DATABRICKS_TOKEN: sentinel, + [marker]: "environment-must-not-serialize", + }, + }, + }); + + let caught: unknown; + try { + if (boundary === "invoke") { + await agent.invoke({ input: "hello" }); + } else { + for await (const _event of agent.stream({ input: "hello" })) { + // Authentication fails before the first event. + } + } + } catch (error) { + caught = error; + } + + const consoleOutput = JSON.stringify([ + ...errorSpy.mock.calls, + ...logSpy.mock.calls, + ]); + errorSpy.mockRestore(); + logSpy.mockRestore(); + + expect(caught).toBeInstanceOf(Error); + const serialized = JSON.stringify(caught); + expect(serialized).not.toContain(sentinel); + expect(serialized).not.toContain(marker); + expect(serialized).not.toContain("environment-must-not-serialize"); + expect(consoleOutput).not.toContain(sentinel); + expect(consoleOutput).not.toContain(marker); + expect(consoleOutput).not.toContain("environment-must-not-serialize"); + }, + ); +}); diff --git a/agent-langchain-ts/tests/framework/tracing.test.ts b/agent-langchain-ts/tests/framework/tracing.test.ts new file mode 100644 index 00000000..c1e68ac4 --- /dev/null +++ b/agent-langchain-ts/tests/framework/tracing.test.ts @@ -0,0 +1,1076 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import http, { type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from "@jest/globals"; + +interface CapturedSpan { + traceId: string; + spanId: string; + parentId: string | null; + name: string; + spanType: string; + inputs: unknown; + outputs: unknown; + attributes: Record; + status: { code: string; description?: string }; + events: Array<{ name: string; attributes?: Record }>; +} + +import * as mlflow from "@mlflow/core"; +import { Config, ConfigError } from "@databricks/sdk-experimental"; +import { RunnableLambda, RunnableSequence } from "@langchain/core/runnables"; +import { + BoundedTraceAccumulator, + buildTracingConfig, + flushTracing, + initializeTracing, + safeLogError, + withAgentRequestTrace, +} from "../../src/framework/tracing.js"; +import { StandardAgent } from "../../src/agent.js"; + +const mlflowSpans: CapturedSpan[] = []; +const exporterRequests: Array<{ + url: string; + headers: http.IncomingHttpHeaders; + json?: Record; +}> = []; +let exporterServer: Server; +let artifactDirectory: string; + +const REQUIRED_ENV = [ + "MLFLOW_EXPERIMENT_ID", + "MLFLOW_UC_CATALOG", + "MLFLOW_UC_SCHEMA", + "MLFLOW_UC_TABLE_PREFIX", +] as const; + +const originalEnv = { ...process.env }; + +const CREDENTIAL_SENTINEL = "task14-round4-sentinel.with/suffix+=tail"; +const CREDENTIAL_MESSAGE_CASES: Array< + [label: string, credentialText: string, redactedText: string] +> = [ + [ + "authorization", + `Authorization: Bearer ${CREDENTIAL_SENTINEL}`, + "Authorization: [REDACTED]", + ], + [ + "authorization header", + `Authorization header: Bearer ${CREDENTIAL_SENTINEL}`, + "Authorization header: [REDACTED]", + ], + [ + "authorization_header", + `authorization_header=Bearer ${CREDENTIAL_SENTINEL}`, + "authorization_header=[REDACTED]", + ], + [ + "authorizationHeader", + `authorizationHeader: \"Bearer ${CREDENTIAL_SENTINEL}\"`, + 'authorizationHeader: "[REDACTED]"', + ], + ["cookie", `Cookie: session=${CREDENTIAL_SENTINEL}`, "Cookie: [REDACTED]"], + [ + "cookie header", + `Cookie header: session=${CREDENTIAL_SENTINEL}`, + "Cookie header: [REDACTED]", + ], + [ + "set-cookie", + `Set-Cookie: \"session=${CREDENTIAL_SENTINEL}\"`, + 'Set-Cookie: "[REDACTED]"', + ], + ["API key", `API key: ${CREDENTIAL_SENTINEL}`, "API key: [REDACTED]"], + ["api-key", `api-key=${CREDENTIAL_SENTINEL}`, "api-key=[REDACTED]"], + [ + "api_key", + `api_key is \"${CREDENTIAL_SENTINEL}\"`, + 'api_key is "[REDACTED]"', + ], + ["apiKey", `apiKey: ${CREDENTIAL_SENTINEL}`, "apiKey: [REDACTED]"], + ["x-api-key", `x-api-key=${CREDENTIAL_SENTINEL}`, "x-api-key=[REDACTED]"], + [ + "Databricks token", + `Databricks token is ${CREDENTIAL_SENTINEL}`, + "Databricks token is [REDACTED]", + ], + [ + "DATABRICKS_TOKEN", + `DATABRICKS_TOKEN=${CREDENTIAL_SENTINEL}`, + "DATABRICKS_TOKEN=[REDACTED]", + ], + [ + "databricksToken", + `databricksToken: '${CREDENTIAL_SENTINEL}'`, + "databricksToken: '[REDACTED]'", + ], + [ + "access token", + `access token is ${CREDENTIAL_SENTINEL}`, + "access token is [REDACTED]", + ], + [ + "access-token", + `access-token=Bearer ${CREDENTIAL_SENTINEL}`, + "access-token=[REDACTED]", + ], + [ + "access_token", + `access_token: ${CREDENTIAL_SENTINEL}`, + "access_token: [REDACTED]", + ], + [ + "accessToken", + `accessToken=\"${CREDENTIAL_SENTINEL}\"`, + 'accessToken="[REDACTED]"', + ], + [ + "refresh token", + `refresh token is ${CREDENTIAL_SENTINEL}`, + "refresh token is [REDACTED]", + ], + [ + "refresh-token", + `refresh-token: '${CREDENTIAL_SENTINEL}'`, + "refresh-token: '[REDACTED]'", + ], + [ + "refresh_token", + `refresh_token=${CREDENTIAL_SENTINEL}`, + "refresh_token=[REDACTED]", + ], + [ + "refreshToken", + `refreshToken=${CREDENTIAL_SENTINEL}`, + "refreshToken=[REDACTED]", + ], + [ + "client secret", + `client secret is ${CREDENTIAL_SENTINEL}`, + "client secret is [REDACTED]", + ], + [ + "client-secret", + `client-secret=${CREDENTIAL_SENTINEL}`, + "client-secret=[REDACTED]", + ], + [ + "client_secret", + `client_secret: \"${CREDENTIAL_SENTINEL}\"`, + 'client_secret: "[REDACTED]"', + ], + [ + "clientSecret", + `clientSecret=${CREDENTIAL_SENTINEL}`, + "clientSecret=[REDACTED]", + ], + ["password", `password is ${CREDENTIAL_SENTINEL}`, "password is [REDACTED]"], + [ + "generic secret", + `secret: '${CREDENTIAL_SENTINEL}'`, + "secret: '[REDACTED]'", + ], + [ + "generic credential", + `credential is ${CREDENTIAL_SENTINEL}`, + "credential is [REDACTED]", + ], +]; + +beforeAll(async () => { + artifactDirectory = await mkdtemp(join(tmpdir(), "mlflow-core-test-")); + exporterServer = http.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + request.on("end", () => { + const body = Buffer.concat(chunks); + const json = request.headers["content-type"]?.includes("application/json") + ? (JSON.parse(body.toString("utf8")) as Record) + : undefined; + const traceInfo = json?.trace?.trace_info; + if (traceInfo) { + traceInfo.tags = { + ...(traceInfo.tags ?? {}), + "mlflow.artifactLocation": pathToFileURL(artifactDirectory).href, + }; + } + exporterRequests.push({ + url: request.url ?? "", + headers: request.headers, + ...(json ? { json } : {}), + }); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(json ?? {})); + }); + }); + await new Promise((resolve) => + exporterServer.listen(0, "127.0.0.1", resolve), + ); + const address = exporterServer.address(); + if (!address || typeof address === "string") { + throw new Error("loopback MLflow exporter did not bind a TCP port"); + } + mlflow.registerOnSpanEndHook((span) => { + mlflowSpans.push({ + traceId: span.traceId, + spanId: span.spanId, + parentId: span.parentId, + name: span.name, + spanType: span.spanType, + inputs: span.inputs, + outputs: span.outputs, + attributes: span.attributes, + status: { + code: span.status.statusCode, + ...(span.status.description + ? { description: span.status.description } + : {}), + }, + events: span.events.map((event) => ({ + name: event.name, + attributes: event.attributes?.["exception.message"] + ? { message: event.attributes["exception.message"] } + : event.attributes, + })), + }); + }); + mlflow.init({ + trackingUri: `http://127.0.0.1:${address.port}`, + experimentId: "123456789", + }); +}); + +afterEach(async () => { + await flushTracing(); + process.env = { ...originalEnv }; + mlflowSpans.length = 0; + exporterRequests.length = 0; +}); + +afterAll(async () => { + await flushTracing(); + await new Promise((resolve, reject) => { + exporterServer.close((error) => (error ? reject(error) : resolve())); + }); + await rm(artifactDirectory, { recursive: true, force: true }); +}); + +describe("MLflow tracing", () => { + test.each(CREDENTIAL_MESSAGE_CASES)( + "redacts %s credentials without leaking a value suffix", + (_label, credentialText, redactedText) => { + const message = safeLogError( + new Error(`Request failed; ${credentialText}; retry later.`), + ).message; + + expect(message).toBe(`Request failed; ${redactedText}; retry later.`); + expect(message).not.toContain(CREDENTIAL_SENTINEL); + expect(message).not.toContain("suffix+=tail"); + }, + ); + + test("preserves ordinary error context containing credential-related words", () => { + const message = + "The token budget is 4096; authorization failed after timeout; " + + "the cookie parser failed; secret rotation is enabled; " + + "credential validation remains unavailable."; + + expect(safeLogError(new Error(message)).message).toBe(message); + }); + + test("does not traverse or enumerate sensitive error properties", () => { + const forbiddenAccesses = { + config: 0, + env: 0, + headers: 0, + cookies: 0, + }; + let enumerationAccesses = 0; + const guardedErrorTarget = Object.assign( + new Error(`clientSecret=${CREDENTIAL_SENTINEL}`), + { + name: "ConfigError", + code: "UNAUTHENTICATED", + }, + ); + + Object.defineProperties(guardedErrorTarget, { + config: { + get: () => { + forbiddenAccesses.config += 1; + throw new Error("config getter traversed"); + }, + }, + env: { + get: () => { + forbiddenAccesses.env += 1; + throw new Error("env getter traversed"); + }, + }, + headers: { + get: () => { + forbiddenAccesses.headers += 1; + throw new Error("headers getter traversed"); + }, + }, + cookies: { + get: () => { + forbiddenAccesses.cookies += 1; + throw new Error("cookies getter traversed"); + }, + }, + }); + + const guardedError = new Proxy(guardedErrorTarget, { + ownKeys() { + enumerationAccesses += 1; + throw new Error("safeLogError must not enumerate the error object"); + }, + }); + + expect(safeLogError(guardedError)).toEqual({ + name: "ConfigError", + code: "UNAUTHENTICATED", + message: "clientSecret=[REDACTED]", + }); + expect(forbiddenAccesses).toEqual({ + config: 0, + env: 0, + headers: 0, + cookies: 0, + }); + expect(enumerationAccesses).toBe(0); + }); + + test("reduces SDK configuration errors to safe actionable fields", () => { + const sentinelCredential = "test-only-sentinel-value"; + const environmentMarker = "TEST_FULL_ENV_MARKER"; + const sdkConfig = new Config({ + env: { + DATABRICKS_TOKEN: sentinelCredential, + [environmentMarker]: "present-only-in-sdk-config", + }, + }); + const error = new ConfigError( + [ + "authentication failed", + `Authorization: Bearer ${sentinelCredential}`, + `Cookie: session=${sentinelCredential}`, + `x-api-key=${sentinelCredential}`, + `DATABRICKS_TOKEN=${sentinelCredential}`, + `CLIENT_SECRET=${sentinelCredential}`, + ].join("; "), + sdkConfig, + ) as ConfigError & { code: string }; + error.code = "UNAUTHENTICATED"; + + const output = JSON.stringify(safeLogError(error)); + + expect(output).toContain("ConfigError"); + expect(output).toContain("UNAUTHENTICATED"); + expect(output).toContain("authentication failed"); + expect(output).toContain("Authorization: [REDACTED]"); + expect(output).not.toContain(sentinelCredential); + expect(output).not.toContain(environmentMarker); + expect(output).not.toContain("present-only-in-sdk-config"); + }); + + test.each(REQUIRED_ENV)("fails startup when %s is missing", (missingName) => { + process.env.MLFLOW_EXPERIMENT_ID = "123456789"; + process.env.MLFLOW_UC_CATALOG = "catalog_test"; + process.env.MLFLOW_UC_SCHEMA = "schema_test"; + process.env.MLFLOW_UC_TABLE_PREFIX = "langchain_test"; + delete process.env[missingName]; + + expect(() => initializeTracing()).toThrow( + `Missing required tracing environment variable: ${missingName}`, + ); + }); + + test("fails startup when the experiment ID is invalid", () => { + process.env.MLFLOW_EXPERIMENT_ID = "not-an-experiment"; + process.env.MLFLOW_UC_CATALOG = "catalog_test"; + process.env.MLFLOW_UC_SCHEMA = "schema_test"; + process.env.MLFLOW_UC_TABLE_PREFIX = "langchain_test"; + + expect(() => initializeTracing()).toThrow( + "Invalid tracing environment variable MLFLOW_EXPERIMENT_ID", + ); + }); + + test("fails startup when a Unity Catalog identifier is invalid", () => { + process.env.MLFLOW_EXPERIMENT_ID = "123456789"; + process.env.MLFLOW_UC_CATALOG = "bad.catalog"; + process.env.MLFLOW_UC_SCHEMA = "schema_test"; + process.env.MLFLOW_UC_TABLE_PREFIX = "langchain_test"; + + expect(() => initializeTracing()).toThrow( + "Invalid tracing environment variable MLFLOW_UC_CATALOG", + ); + }); + + test("fails startup when the tracking URI is invalid", () => { + process.env.MLFLOW_TRACKING_URI = "ftp://unsupported"; + process.env.MLFLOW_EXPERIMENT_ID = "123456789"; + process.env.MLFLOW_UC_CATALOG = "catalog_test"; + process.env.MLFLOW_UC_SCHEMA = "schema_test"; + process.env.MLFLOW_UC_TABLE_PREFIX = "langchain_test"; + + expect(() => initializeTracing()).toThrow( + "Invalid tracing environment variable MLFLOW_TRACKING_URI", + ); + }); + + test("builds the exact UC trace location for MLflow core", () => { + process.env.MLFLOW_TRACKING_URI = " "; + process.env.MLFLOW_EXPERIMENT_ID = "123456789"; + process.env.MLFLOW_UC_CATALOG = "catalog_test"; + process.env.MLFLOW_UC_SCHEMA = "schema_test"; + process.env.MLFLOW_UC_TABLE_PREFIX = "langchain_test"; + + expect(buildTracingConfig()).toEqual({ + trackingUri: "databricks", + experimentId: "123456789", + traceLocation: { + catalogName: "catalog_test", + schemaName: "schema_test", + tablePrefix: "langchain_test", + }, + }); + }); + + test("sets app, session, user, and request identity on the exported trace", async () => { + process.env.DATABRICKS_APP_NAME = "deployed-langchain-agent"; + + await withAgentRequestTrace( + { input: "identify this trace" }, + { + sessionId: "session-123", + userId: "user-456", + requestId: "request-789", + }, + async (trace) => { + trace.setOutputs({ output: "identified" }); + return "identified"; + }, + ); + await mlflow.flushTraces(); + + const infoRequest = exporterRequests.find((request) => + request.url.endsWith("/api/3.0/mlflow/traces"), + ); + expect(infoRequest?.json?.trace?.trace_info?.trace_metadata).toMatchObject({ + "mlflow.trace.session": "session-123", + "mlflow.trace.user": "user-456", + "appkit.app.name": "deployed-langchain-agent", + "appkit.request.id": "request-789", + }); + }); + + test("traces and aggregates every model iteration in an invocation", async () => { + const runnable = { + async invoke(_input: unknown, options?: Record) { + const callback = options?.callbacks?.[0]; + if (!callback) + throw new Error("production invocation did not install tracing"); + + await callback.handleChainStart( + { id: ["langgraph", "agent"] }, + { question: "weather in Paris" }, + "agent-run", + undefined, + [], + {}, + "LangGraph", + ); + await callback.handleChatModelStart( + { id: ["databricks", "chat"] }, + [[{ role: "user", content: "weather in Paris" }]], + "model-run-1", + "agent-run", + { model: "model-one" }, + [], + { ls_provider: "databricks" }, + "model-one", + ); + await callback.handleLLMNewToken( + "checking", + { prompt: 0, completion: 0 }, + "model-run-1", + ); + await callback.handleLLMEnd( + { + generations: [ + [ + { + message: { + content: "checking", + usage_metadata: { + input_tokens: 10, + output_tokens: 4, + total_tokens: 14, + input_token_details: { + cache_read: 3, + cache_creation: 1, + }, + }, + response_metadata: { + model_name: "model-one", + finish_reason: "tool_calls", + }, + }, + generationInfo: { finish_reason: "tool_calls" }, + }, + ], + ], + llmOutput: {}, + }, + "model-run-1", + ); + await callback.handleChatModelStart( + { id: ["databricks", "chat"] }, + [[{ role: "user", content: "weather in Paris" }]], + "model-run-2", + "agent-run", + { model: "model-two" }, + [], + { ls_provider: "databricks" }, + "model-two", + ); + await callback.handleLLMNewToken( + "sunny", + { prompt: 0, completion: 0 }, + "model-run-2", + ); + await callback.handleLLMEnd( + { + generations: [ + [ + { + message: { + content: "sunny", + usage_metadata: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_token_details: { cache_read: 1 }, + }, + response_metadata: { + model_name: "model-two", + finish_reason: "stop", + }, + }, + generationInfo: { finish_reason: "stop" }, + }, + ], + ], + llmOutput: { total_cost_usd: 0.01 }, + }, + "model-run-2", + ); + await callback.handleChainEnd({ answer: "sunny" }, "agent-run"); + return { messages: [{ role: "assistant", content: "sunny" }] }; + }, + }; + const agent = new StandardAgent(runnable as any, "helpful"); + + await withAgentRequestTrace( + { input: "weather in Paris" }, + { sessionId: "session-1", userId: "user-1", requestId: "request-1" }, + async (trace) => { + const output = await agent.invoke({ input: "weather in Paris" }); + trace.setOutputs(output); + return output; + }, + ); + + const roots = mlflowSpans.filter( + (span) => span.spanType === "AGENT" && span.parentId === null, + ); + const models = mlflowSpans.filter((span) => span.spanType === "CHAT_MODEL"); + expect(roots).toHaveLength(1); + expect(roots[0].status.code).toBe("STATUS_CODE_OK"); + expect(models.map((span) => span.attributes["langchain.run_id"])).toEqual([ + "model-run-1", + "model-run-2", + ]); + expect(models.map((span) => span.attributes["appkit.usage"])).toEqual([ + { + inputTokens: 10, + outputTokens: 4, + totalTokens: 14, + cacheReadInputTokens: 3, + cacheCreationInputTokens: 1, + costAvailable: false, + }, + { + inputTokens: 5, + outputTokens: 2, + totalTokens: 7, + cacheReadInputTokens: 1, + costAvailable: true, + costUsd: 0.01, + }, + ]); + expect(models.map((span) => span.attributes["appkit.model"])).toEqual([ + "model-one", + "model-two", + ]); + expect(models.map((span) => span.attributes["appkit.provider"])).toEqual([ + "databricks", + "databricks", + ]); + for (const model of models) { + expect(model.attributes["appkit.ttft_ms"]).toEqual(expect.any(Number)); + expect(model.attributes["appkit.stream_duration_ms"]).toEqual( + expect.any(Number), + ); + expect( + model.attributes["appkit.stream_duration_ms"], + ).toBeGreaterThanOrEqual(model.attributes["appkit.ttft_ms"]); + } + expect(models[0].attributes["appkit.cost_available"]).toBe(false); + expect(models[0].attributes).not.toHaveProperty("appkit.cost_usd"); + expect(models[1].attributes["appkit.cost_available"]).toBe(true); + expect(models[1].attributes["appkit.cost_usd"]).toBe(0.01); + expect(roots[0].attributes["appkit.usage"]).toEqual({ + inputTokens: 15, + outputTokens: 6, + totalTokens: 21, + cacheReadInputTokens: 4, + cacheCreationInputTokens: 1, + costAvailable: false, + }); + expect(roots[0].attributes["appkit.usage"]).not.toHaveProperty("costUsd"); + }); + + test("parses ChatDatabricks camelCase token usage", async () => { + const runnable = { + async invoke(_input: unknown, options?: Record) { + const callback = options?.callbacks?.[0]; + if (!callback) + throw new Error("production invocation did not install tracing"); + + await callback.handleChatModelStart( + { id: ["databricks", "chat"] }, + [[{ role: "user", content: "count these tokens" }]], + "adapter-model-run", + undefined, + { model: "databricks-adapter-model" }, + [], + { ls_provider: "databricks" }, + "databricks-adapter-model", + ); + await callback.handleLLMEnd( + { + generations: [ + [ + { + message: { content: "counted" }, + generationInfo: { finish_reason: "stop" }, + }, + ], + ], + llmOutput: { + tokenUsage: { + promptTokens: 11, + completionTokens: 5, + totalTokens: 16, + cacheReadInputTokens: 3, + cacheCreationInputTokens: 2, + }, + }, + }, + "adapter-model-run", + ); + return { messages: [{ role: "assistant", content: "counted" }] }; + }, + }; + const agent = new StandardAgent(runnable as any, "helpful"); + + await withAgentRequestTrace( + { input: "count these tokens" }, + { + sessionId: "session-usage", + userId: "user-usage", + requestId: "request-usage", + }, + async () => agent.invoke({ input: "count these tokens" }), + ); + + const root = mlflowSpans.find( + (span) => span.spanType === "AGENT" && span.parentId === null, + ); + const model = mlflowSpans.find( + (span) => span.attributes["langchain.run_id"] === "adapter-model-run", + ); + const expectedUsage = { + inputTokens: 11, + outputTokens: 5, + totalTokens: 16, + cacheReadInputTokens: 3, + cacheCreationInputTokens: 2, + costAvailable: false, + }; + expect(model?.attributes["appkit.usage"]).toEqual(expectedUsage); + expect(model?.attributes["mlflow.chat.tokenUsage"]).toEqual({ + input_tokens: 11, + output_tokens: 5, + total_tokens: 16, + cache_read_input_tokens: 3, + cache_creation_input_tokens: 2, + }); + expect(root?.attributes["appkit.usage"]).toEqual(expectedUsage); + }); + + test("records complete tool success and error lifecycles by run ID", async () => { + const runnable = { + async invoke(_input: unknown, options?: Record) { + const callback = options?.callbacks?.[0]; + if (!callback) + throw new Error("production invocation did not install tracing"); + await callback.handleChainStart( + { id: ["langgraph", "agent"] }, + { question: "use tools" }, + "agent-tools", + ); + await callback.handleToolStart( + { id: ["tools", "weather"] }, + '{"city":"Paris"}', + "tool-success", + "agent-tools", + [], + {}, + "weather", + ); + await callback.handleToolEnd( + { temperature: 21, conditions: "sunny" }, + "tool-success", + "agent-tools", + ); + await callback.handleToolStart( + { id: ["tools", "calendar"] }, + '{"date":"tomorrow"}', + "tool-error", + "agent-tools", + [], + {}, + "calendar", + ); + await callback.handleToolError( + new Error("calendar unavailable"), + "tool-error", + "agent-tools", + ); + await callback.handleChainEnd( + { answer: "partial result" }, + "agent-tools", + ); + return { messages: [{ role: "assistant", content: "partial result" }] }; + }, + }; + const agent = new StandardAgent(runnable as any, "helpful"); + + await withAgentRequestTrace( + { input: "use tools" }, + { sessionId: "session-2", userId: "user-2", requestId: "request-2" }, + async () => agent.invoke({ input: "use tools" }), + ); + + const tools = mlflowSpans.filter((span) => span.spanType === "TOOL"); + expect(tools.map((span) => span.attributes["langchain.run_id"])).toEqual([ + "tool-success", + "tool-error", + ]); + expect(tools[0].inputs).toEqual({ city: "Paris" }); + expect(tools[0].outputs).toEqual({ temperature: 21, conditions: "sunny" }); + expect(tools[0].status.code).toBe("STATUS_CODE_OK"); + expect(tools[1].inputs).toEqual({ date: "tomorrow" }); + expect(tools[1].outputs).toEqual({ + partial_output: { available: false, reason: "no output produced" }, + error: "calendar unavailable", + }); + expect(tools[1].status.code).toBe("STATUS_CODE_ERROR"); + expect(tools[1].events).toEqual([ + { name: "exception", attributes: { message: "calendar unavailable" } }, + ]); + }); + + test("traces retrieval and nested agent decisions beneath the chain", async () => { + const runnable = { + async invoke(_input: unknown, options?: Record) { + const callback = options?.callbacks?.[0]; + if (!callback) + throw new Error("production invocation did not install tracing"); + await callback.handleChainStart( + { id: ["langgraph", "agent"] }, + { question: "find policy" }, + "agent-retrieval", + ); + await callback.handleAgentAction( + { + tool: "policy_search", + toolInput: { query: "refunds", apiKey: "action-secret" }, + log: "search", + }, + "agent-retrieval", + ); + await callback.handleRetrieverStart( + { id: ["retrievers", "policy"] }, + "refund policy", + "retriever-run", + "agent-retrieval", + [], + {}, + "policy-retriever", + ); + await callback.handleRetrieverEnd( + [ + { + pageContent: "Refunds are available for 30 days", + metadata: { source: "policy" }, + }, + ], + "retriever-run", + "agent-retrieval", + ); + await callback.handleAgentEnd( + { + returnValues: { output: "30 days" }, + log: "done", + password: "finish-secret", + }, + "agent-retrieval", + ); + await callback.handleChainEnd({ answer: "30 days" }, "agent-retrieval"); + return { messages: [{ role: "assistant", content: "30 days" }] }; + }, + }; + const agent = new StandardAgent(runnable as any, "helpful"); + + await withAgentRequestTrace( + { input: "find policy" }, + { sessionId: "session-3", userId: "user-3", requestId: "request-3" }, + async () => agent.invoke({ input: "find policy" }), + ); + + const root = mlflowSpans.find( + (span) => span.spanType === "AGENT" && span.parentId === null, + ); + const chain = mlflowSpans.find( + (span) => + span.spanType === "CHAIN" && + span.parentId === root?.spanId && + span.attributes["langchain.run_id"] === "agent-retrieval", + ); + const retriever = mlflowSpans.find((span) => span.spanType === "RETRIEVER"); + expect(root).toBeDefined(); + expect(chain?.parentId).toBe(root?.spanId); + expect(retriever?.parentId).toBe(chain?.spanId); + expect(retriever?.attributes["langchain.run_id"]).toBe("retriever-run"); + expect(retriever?.inputs).toEqual({ query: "refund policy" }); + expect(retriever?.outputs).toEqual([ + { + pageContent: "Refunds are available for 30 days", + metadata: { source: "policy" }, + }, + ]); + const decisions = mlflowSpans.filter( + (span) => + span.spanType === "CHAIN" && + ["langchain.agent.action", "langchain.agent.end"].includes(span.name), + ); + expect(decisions).toHaveLength(2); + expect(decisions.map((span) => span.parentId)).toEqual([ + chain?.spanId, + chain?.spanId, + ]); + expect( + decisions.map((span) => span.attributes["langchain.run_id"]), + ).toEqual(["agent-retrieval", "agent-retrieval"]); + expect(decisions.map((span) => span.inputs)).toEqual([ + { + tool: "policy_search", + toolInput: { query: "refunds", apiKey: "[REDACTED]" }, + log: "search", + }, + { + returnValues: { output: "30 days" }, + log: "done", + password: "[REDACTED]", + }, + ]); + expect(decisions.map((span) => span.outputs)).toEqual([ + { + tool: "policy_search", + toolInput: { query: "refunds", apiKey: "[REDACTED]" }, + log: "search", + }, + { + returnValues: { output: "30 days" }, + log: "done", + password: "[REDACTED]", + }, + ]); + expect(decisions.map((span) => span.status.code)).toEqual([ + "STATUS_CODE_OK", + "STATUS_CODE_OK", + ]); + expect(chain?.events).toEqual([]); + expect(JSON.stringify(decisions)).not.toContain("action-secret"); + expect(JSON.stringify(decisions)).not.toContain("finish-secret"); + }); + + test("preserves real RunnableSequence names and nested chain parents", async () => { + const sequence = RunnableSequence.from([ + RunnableLambda.from( + async (input: Record) => input, + ).withConfig({ runName: "prepare-input" }), + RunnableLambda.from(async () => ({ + messages: [{ role: "assistant", content: "nested answer" }], + })).withConfig({ runName: "produce-answer" }), + ]).withConfig({ runName: "outer-sequence" }); + const agent = new StandardAgent(sequence as any, "helpful"); + + await withAgentRequestTrace( + { input: "run nested chains" }, + { + sessionId: "session-nested", + userId: "user-nested", + requestId: "request-nested", + }, + async () => agent.invoke({ input: "run nested chains" }), + ); + + const chains = mlflowSpans.filter((span) => span.spanType === "CHAIN"); + expect(chains.map((span) => span.name).sort()).toEqual( + ["outer-sequence", "prepare-input", "produce-answer"].sort(), + ); + const outer = chains.find((span) => span.name === "outer-sequence")!; + const prepare = chains.find((span) => span.name === "prepare-input")!; + const produce = chains.find((span) => span.name === "produce-answer")!; + expect(prepare.parentId).toBe(outer.spanId); + expect(produce.parentId).toBe(outer.spanId); + expect(prepare.attributes["langchain.parent_run_id"]).toBe( + outer.attributes["langchain.run_id"], + ); + expect(produce.attributes["langchain.parent_run_id"]).toBe( + outer.attributes["langchain.run_id"], + ); + }); + + test("bounds complete captures and redacts structured and free-form secrets", async () => { + await withAgentRequestTrace( + { + prompt: "summarize", + authorization: "Bearer root-secret", + notes: "Authorization: Bearer escaped-secret", + payload: "x".repeat(70_000), + }, + { sessionId: "session-4", userId: "user-4", requestId: "request-4" }, + async (trace) => { + trace.setOutputs({ + answer: "done", + apiKey: "output-secret", + detail: "password='quoted-secret'", + }); + return "done"; + }, + ); + + const root = mlflowSpans.find( + (span) => span.spanType === "AGENT" && span.parentId === null, + ); + expect(root?.inputs).toEqual({ + truncated: true, + originalBytes: expect.any(Number), + sha256: expect.stringMatching(/^[0-9a-f]{64}$/), + preview: expect.any(String), + }); + expect((root?.inputs as any).originalBytes).toBeGreaterThan(65_536); + expect((root?.inputs as any).preview).toContain("[REDACTED]"); + expect(JSON.stringify(root?.inputs)).not.toContain("root-secret"); + expect(JSON.stringify(root?.inputs)).not.toContain("escaped-secret"); + expect(root?.outputs).toEqual({ + answer: "done", + apiKey: "[REDACTED]", + detail: "password='[REDACTED]'", + }); + }); + + test("records and rethrows request failures without exposing secrets", async () => { + await expect( + withAgentRequestTrace( + { input: "fail" }, + { sessionId: "session-5", userId: "user-5", requestId: "request-5" }, + async () => { + throw new Error("Authorization: Bearer request-secret"); + }, + ), + ).rejects.toThrow("Authorization: [REDACTED]"); + + const root = mlflowSpans.find( + (span) => span.spanType === "AGENT" && span.parentId === null, + ); + expect(root?.status.code).toBe("STATUS_CODE_ERROR"); + expect(root?.outputs).toEqual({ + partial_output: { available: false, reason: "no output produced" }, + error: "Authorization: [REDACTED]", + }); + expect(JSON.stringify(root)).not.toContain("request-secret"); + }); + + test("keeps a handled streaming failure marked as an error", async () => { + const result = await withAgentRequestTrace( + { input: "stream failure" }, + { sessionId: "session-6", userId: "user-6", requestId: "request-6" }, + async (trace) => { + return trace.recordError( + new Error("Authorization: Bearer stream-secret"), + ); + }, + ); + + expect(result.value).toBe("Authorization: [REDACTED]"); + const root = mlflowSpans.find( + (span) => span.spanType === "AGENT" && span.parentId === null, + ); + expect(root?.status.code).toBe("STATUS_CODE_ERROR"); + expect(root?.outputs).toEqual({ + partial_output: { available: false, reason: "no output produced" }, + error: "Authorization: [REDACTED]", + }); + expect(JSON.stringify(root)).not.toContain("stream-secret"); + }); + + test("bounds streaming capture incrementally", () => { + const capture = new BoundedTraceAccumulator(1024); + for (let index = 0; index < 20; index += 1) { + capture.add({ index, delta: "x".repeat(200), token: `secret-${index}` }); + } + + const snapshot = capture.snapshot(); + expect(snapshot).toEqual({ + truncated: true, + originalBytes: expect.any(Number), + sha256: expect.stringMatching(/^[0-9a-f]{64}$/), + preview: expect.any(String), + }); + expect((snapshot as any).originalBytes).toBeGreaterThan(1024); + expect( + Buffer.byteLength((snapshot as any).preview, "utf8"), + ).toBeLessThanOrEqual(1024); + expect(JSON.stringify(snapshot)).not.toContain("secret-0"); + }); +}); diff --git a/agent-langgraph-advanced/agent_server/agent.py b/agent-langgraph-advanced/agent_server/agent.py index 8f616629..90389067 100644 --- a/agent-langgraph-advanced/agent_server/agent.py +++ b/agent-langgraph-advanced/agent_server/agent.py @@ -1,8 +1,8 @@ import logging from datetime import datetime from typing import Any, AsyncGenerator, Optional, Sequence, TypedDict +from uuid import uuid4 -import mlflow from databricks.sdk import WorkspaceClient from databricks_langchain import ChatDatabricks from fastapi import HTTPException @@ -12,6 +12,7 @@ from langgraph.graph.message import add_messages from langgraph.store.base import BaseStore from mlflow.genai.agent_server import invoke, stream +from mlflow.entities import SpanType from mlflow.types.responses import ( ResponsesAgentRequest, ResponsesAgentResponse, @@ -23,11 +24,22 @@ from agent_server.prompts import SYSTEM_PROMPT from agent_server.utils import ( _get_or_create_thread_id, + get_mcp_tools, get_user_workspace_client, init_mcp_client, process_agent_astream_events, ) +from agent_server.tracing import ( + BoundedTraceAccumulator, + agent_request_span, + configure_mlflow_tracing, + set_request_trace_identity, + traced_async_operation, + traced_operation, +) from agent_server.utils_memory import ( + LakebaseConfig, + TracedCheckpointSaver, acquire_lakebase_resources, get_lakebase_access_error_message, get_user_id, @@ -36,12 +48,15 @@ ) logger = logging.getLogger(__name__) -mlflow.langchain.autolog() logging.getLogger("mlflow.utils.autologging_utils").setLevel(logging.ERROR) -sp_workspace_client = WorkspaceClient() LLM_ENDPOINT_NAME = "databricks-gpt-5-2" -LAKEBASE_CONFIG = init_lakebase_config() +try: + LAKEBASE_CONFIG = init_lakebase_config() +except ValueError: + # Keep imports offline-safe. The request path still fails with the normal + # Lakebase guidance if no endpoint/project is configured. + LAKEBASE_CONFIG = LakebaseConfig(None, None, None) @tool @@ -61,11 +76,12 @@ async def init_agent( workspace_client: Optional[WorkspaceClient] = None, checkpointer: Optional[Any] = None, ): + configure_mlflow_tracing() tools = [get_current_time] + memory_tools() # To use MCP server tools instead, uncomment the below lines: - # mcp_client = init_mcp_client(workspace_client or sp_workspace_client) + # mcp_client = init_mcp_client(workspace_client or WorkspaceClient()) # try: - # tools.extend(await mcp_client.get_tools()) + # tools.extend(await get_mcp_tools(mcp_client)) # except Exception: # logger.warning("Failed to fetch MCP tools. Continuing without MCP tools.", exc_info=True) @@ -99,44 +115,87 @@ async def invoke_handler(request: ResponsesAgentRequest) -> ResponsesAgentRespon async def stream_handler( request: ResponsesAgentRequest, ) -> AsyncGenerator[ResponsesAgentStreamEvent, None]: + configure_mlflow_tracing() thread_id = _get_or_create_thread_id(request) - mlflow.update_current_trace(metadata={"mlflow.trace.session": thread_id}) - user_id = get_user_id(request) if not user_id: logger.warning("No user_id provided - memory features will not be available") - config: dict[str, Any] = {"configurable": {"thread_id": thread_id}} - if user_id: - config["configurable"]["user_id"] = user_id - - input_state: dict[str, Any] = { - "messages": to_chat_completions_input([i.model_dump() for i in request.input]), - "custom_inputs": dict(request.custom_inputs or {}), - } - - try: - async with acquire_lakebase_resources(LAKEBASE_CONFIG) as (checkpointer, store): - config["configurable"]["store"] = store - - # By default, uses service principal credentials. - # For on-behalf-of user authentication, pass get_user_workspace_client() to init_agent. - agent = await init_agent(store=store, checkpointer=checkpointer) - - async for event in process_agent_astream_events( - agent.astream(input_state, config, stream_mode=["updates", "messages"]) + custom_inputs = dict(request.custom_inputs or {}) + request_id = ( + custom_inputs.get("request_id") + or (request.metadata or {}).get("request_id") + or str(uuid4()) + ) + trace_user_id = user_id or request.user or "anonymous" + + with agent_request_span( + "langgraph_advanced.request", request.model_dump() + ) as request_trace: + set_request_trace_identity( + str(thread_id), + str(trace_user_id), + str(request_id), + "agent-langgraph-advanced", + ) + config: dict[str, Any] = {"configurable": {"thread_id": thread_id}} + if user_id: + config["configurable"]["user_id"] = user_id + + with traced_operation( + "langgraph.state.serialize", + SpanType.PARSER, + { + "input": [item.model_dump() for item in request.input], + "custom_inputs": custom_inputs, + }, + ) as parser: + input_state: dict[str, Any] = { + "messages": to_chat_completions_input( + [item.model_dump() for item in request.input] + ), + "custom_inputs": custom_inputs, + } + parser.set_outputs(input_state) + + try: + async with traced_async_operation( + "lakebase.resources.acquire", + SpanType.MEMORY, + {"lakebase": LAKEBASE_CONFIG.description}, + ) as resource_span: + async with acquire_lakebase_resources(LAKEBASE_CONFIG) as ( + checkpointer, + store, + ): + resource_span.set_outputs({"checkpointer": True, "store": True}) + config["configurable"]["store"] = store + agent = await init_agent( + store=store, + checkpointer=TracedCheckpointSaver(checkpointer), + ) + outputs = BoundedTraceAccumulator() + async for event in process_agent_astream_events( + agent.astream( + input_state, + config, + stream_mode=["updates", "messages"], + ) + ): + outputs.add(event.model_dump(exclude_none=True)) + yield event + request_trace.set_outputs({"events": outputs.snapshot()}) + except Exception as e: + error_msg = str(e).lower() + if any( + keyword in error_msg + for keyword in ["lakebase", "pg_hba", "postgres", "database instance"] ): - yield event - except Exception as e: - error_msg = str(e).lower() - # Check for Lakebase access/connection errors - if any( - keyword in error_msg - for keyword in ["lakebase", "pg_hba", "postgres", "database instance"] - ): - logger.error("Lakebase access error: %s", e) - raise HTTPException( - status_code=503, - detail=get_lakebase_access_error_message(LAKEBASE_CONFIG.description), - ) from e - raise + logger.error("Lakebase access error: %s", e) + raise HTTPException( + status_code=503, + detail=get_lakebase_access_error_message( + LAKEBASE_CONFIG.description + ), + ) from e + raise diff --git a/agent-langgraph-advanced/agent_server/tracing.py b/agent-langgraph-advanced/agent_server/tracing.py new file mode 100644 index 00000000..f9da209f --- /dev/null +++ b/agent-langgraph-advanced/agent_server/tracing.py @@ -0,0 +1,642 @@ +"""MLflow tracing primitives shared by the advanced LangGraph request path.""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import threading +import time +from contextlib import asynccontextmanager, contextmanager +from dataclasses import asdict, is_dataclass +from typing import Any, AsyncIterator, Iterator, Mapping + +import mlflow +from mlflow.entities import SpanType +from mlflow.langchain import langchain_tracer as _mlflow_langchain_tracer + +if not hasattr(_mlflow_langchain_tracer, "_appkit_original_tracer"): + _mlflow_langchain_tracer._appkit_original_tracer = ( + _mlflow_langchain_tracer.MlflowLangchainTracer + ) +_MlflowLangchainTracer = _mlflow_langchain_tracer._appkit_original_tracer + +_langchain_autolog = mlflow.langchain.autolog + +_MAX_CAPTURE_BYTES = 64 * 1024 +_MAX_STREAM_PREVIEW_BYTES = _MAX_CAPTURE_BYTES // 2 +_SECRET_KEY = re.compile( + r"(?:authorization|api[-_]?key|cookie|credential|password|secret|token)", + re.IGNORECASE, +) +_SECRET_LABEL = r"(?:authorization|api[-_]?key|cookie|credential|password|secret|token)" +_QUOTED_SECRET_TEXT = re.compile( + rf"(?P\b{_SECRET_LABEL}\b\s*(?::|=)\s*)" + r"""(?P'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*")""", + re.IGNORECASE, +) +_EXPLICIT_BEARER_SECRET_TEXT = re.compile( + rf"(?P\b{_SECRET_LABEL}\b\s*(?::|=)\s*bearer\s+)" + r"(?P[^\s,;)\]}]+)", + re.IGNORECASE, +) +_UNQUOTED_SECRET_TEXT = re.compile( + rf"(?P\b{_SECRET_LABEL}\b\s*(?::|=)\s*)" + r"""(?P(?!(?:bearer)\b\s)[^\s,;)\]}"']+)""", + re.IGNORECASE, +) +_STANDALONE_SECRET_TEXT = re.compile( + r"(?P