crosswatch
Unified observability for DATA and AI products.
One plugin-driven API, one config format, one local store, and one CLI across data quality and LLM/agent observability — by wrapping and composing best-in-class OSS rather than reinventing it.
🚧 Alpha. See
docs/the architecture notes for the full design.
Table of contents
- Features
- What's unique: data → AI impact correlation
- Why
- Install
- Connectors
- Checks
- Evals
- Alerts
- Exporters
- 5-minute quickstart
- Configuration — YAML, JSON, or Python
- Examples
- Local demo services
- Running in the cloud
- Architecture at a glance
- Known limitations / roadmap
- Development
- License
Features
- Data: freshness, volume, schema-change, null/duplicate, and drift checks as portable Ibis expressions that run unchanged on DuckDB, SQLite, Postgres, MySQL, ClickHouse, Snowflake, BigQuery, Databricks — plus dbt artifact ingestion (Great Expectations / Soda providers planned). Failures carry the reason, a suggested fix, and a sample of the exact offending rows/keys/bins.
- AI: OpenTelemetry-native tracing for OpenAI/Anthropic (and LangChain/LlamaIndex via OpenInference), with token / cost / latency on every span using GenAI semantic conventions.
- MCP: trace every tool call and evaluate tool-selection accuracy, argument correctness, and task success — turn recorded sessions into regression suites.
- Evals: evaluator plugin API + CI mode that fails the build on a score regression. Ships an LLM-as-judge evaluator (deterministic or real-LLM grading) and the MCP tool-use evals above (DeepEval/Ragas providers planned, not yet built — see Evals).
- Store / alerts / UI: default DuckDB/Parquet store, OTLP export to any backend (Phoenix/Langfuse/Jaeger/Datadog), Slack/webhook alerts, and a minimal FastAPI dashboard.
What's unique: data → AI impact correlation
Data-observability tools (Great Expectations, Soda, Monte Carlo) stop at the warehouse. LLM-observability tools (Langfuse, Phoenix, LangSmith) start at the prompt. Nothing joins them — so when a RAG eval regresses, you can't tell from one tool whether a data problem upstream caused it.
crosswatch records both data-quality results and AI evals in one store, so it correlates them. Tag an eval with the data assets it depends on, and crosswatch links a failing check on that asset to the AI output it degraded:
crosswatch impact -c crosswatch.yml
Data → AI impact (1 link):
[warehouse.customers] null_duplicate FAILED ⇒ IMPACTED
rag:answer_quality=0.42 (12 min later)
data cause: email null_fraction=0.100 > 0.0; 1 duplicate rows over key ['id']
from crosswatch.models import EvalResult
from crosswatch.impact import correlate
# Declare the data an AI output depends on:
EvalResult(evaluator="rag", metric="answer_quality", score=0.42, threshold=0.8,
assets=["warehouse.customers"])
links = correlate(checks, evals) # failing check on an asset ⟶ the evals that use it
This answers a question no single-domain tool can: which data problem is
degrading my AI, and which AI outputs are at risk from this data problem? It
shows up as a dedicated card on the dashboard, and crosswatch impact exits non-zero
when a data failure impacted a failing eval — a CI gate across the data/AI
boundary.
Why
Teams stitch together Great Expectations for data quality, OpenTelemetry for tracing, DeepEval/Ragas for evals, and bespoke MCP logging — each with its own config, storage, and alerting. crosswatch is the composition layer: it doesn't re-implement expectation engines or tracers, it gives them one surface.
Install
pip install crosswatch # thin core (DuckDB + native checks)
pip install 'crosswatch[postgres,anthropic,server]' # add only what you need
Extras: sqlite postgres mysql clickhouse snowflake bigquery
databricks openai anthropic langchain llama-index mcp gx soda
evidently deepeval ragas otlp phoenix langfuse server slack.
Managed with uv.
gx,soda,deepeval, andragascurrently only install the underlying library — see Known limitations / roadmap.
Connectors
One adapter per source, all exposing the same interface
(get_schema / profile / run_check / get_query_history) — so a check
definition runs unchanged on any of them. Referenced in config by type:
| Source | type |
Extra to install | Status |
|---|---|---|---|
| DuckDB | duckdb |
(core) | ✅ default; file or in-memory |
| SQLite | sqlite |
sqlite (no deps) |
✅ file-based |
| Postgres | postgres |
postgres |
✅ + query history |
| MySQL | mysql |
mysql |
✅ + query history |
| ClickHouse | clickhouse |
clickhouse |
✅ + query history |
| Snowflake | snowflake |
snowflake |
⚙️ needs account + credentials |
| BigQuery | bigquery |
bigquery |
⚙️ needs project + ADC |
| Databricks | databricks |
databricks |
⚙️ needs workspace + token |
pip install 'crosswatch[mysql,clickhouse]'
Third parties can publish their own connector by registering a
crosswatch.connectors entry point — no change to the core.
Checks
Every check is a Pydantic model discovered via the crosswatch.checks
entry-point group; set it in config with type. List what's available
(built-ins plus any third-party checks installed) at any time:
from crosswatch import plugins
print(plugins.available(plugins.CHECKS))
# ['drift', 'freshness', 'null_duplicate', 'schema_change', 'volume']
python -c "from crosswatch import plugins; print(plugins.available(plugins.CHECKS))"
Every check also takes the common fields table, severity
(error/warn/info, default error), and an optional name:
type |
Asserts | Key fields |
|---|---|---|
freshness |
newest row in column isn't older than max_age |
column, max_age (e.g. "24h") |
volume |
row count falls within bounds | min_rows, max_rows |
schema_change |
live schema matches expectations | expected_columns, expected_types, allow_new |
null_duplicate |
null fraction + key uniqueness | columns, max_null_fraction, key |
drift |
PSI between a reference and current distribution | column, reference_table, threshold |
Third parties can publish their own check by registering a
crosswatch.checks entry point — no change to the core.
Evals
An evaluator scores an EvalCase (an AI input/output/context — e.g. a RAG
answer or an agent's tool call) and returns an EvalResult. Every evaluator
returns the same result shape, so a rule-based score, an LLM judge, and a
replayed MCP session all flow into the same store, CI mode, and alerts.
Evaluators are discovered via the crosswatch.evaluators entry-point group:
from crosswatch import plugins
print(plugins.available(plugins.EVALUATORS))
# ['llm_judge']
LLM-as-judge
LLMJudge scores a case 0.0–1.0 against a rubric. Inject a deterministic
score_fn for CI/tests (no LLM call needed), or pass a real client to grade
with a model:
from crosswatch.evals import LLMJudge, run_dataset
from crosswatch.models import EvalCase
cases = [
EvalCase(id="1", input="What is the capital of France?",
context=["Paris is the capital of France."], output="Paris"),
]
# CI-friendly: deterministic, no LLM call
judge = LLMJudge(metric="answer_quality", threshold=0.8,
score_fn=lambda c: 1.0 if "Paris" in c.output else 0.0)
results = run_dataset(judge, cases)
# Or grade with a real LLM:
import anthropic
judge = LLMJudge(metric="answer_quality", threshold=0.8,
client=anthropic.Anthropic(), model="claude-opus-4-8")
results = run_dataset(judge, cases)
CLI note:
crosswatch eval -c crosswatch.ymlcurrently only dispatchesmcp_tool_selection/mcpevaluators configured incrosswatch.yml'sevals:block (see Examples below) — any other evaluator name, includingllm_judge, is skipped with a warning.LLMJudgeis Python-API-only for now.
MCP tool-use evals
tool_selection_accuracy, argument_correctness, and task_success_rate
score recorded MCP sessions against ground truth, and run_regression replays
them through a live agent to catch drift between model/prompt versions — see
MCP — trace tool calls + eval tool selection for a worked example.
gx/soda/deepeval/ragasevaluator providers aren't built yet — see Known limitations / roadmap.
Alerts
An alert channel delivers a failing check/eval result somewhere (Slack,
webhook). dispatch turns results into AlertEvents and fans them out to
whichever channels you pass it — only failures by default, or every result
with on="always". Channels are discovered via the crosswatch.alerts
entry-point group:
from crosswatch import plugins
print(plugins.available(plugins.ALERTS))
# ['slack', 'webhook']
from crosswatch.alerts.base import dispatch
from crosswatch.alerts.webhook import WebhookChannel
channel = WebhookChannel(url="https://example.com/hooks/crosswatch")
sent = dispatch(results, [channel]) # failures only
sent = dispatch(results, [channel], on="always") # every result
channel |
Delivers to | Key fields |
|---|---|---|
webhook |
any URL, as a POST of JSON |
url, timeout |
slack |
a Slack incoming webhook | url (formats as an emoji + title/body Slack message) |
Config note:
crosswatch.yml'salerts:block parses intoAlertConfigobjects, but neithercrosswatch checknorcrosswatch evalcalldispatchyet — configuringalerts:today is a no-op. Calldispatchyourself in the Python API until the CLI wires it in; see Known limitations / roadmap.
Third parties can publish their own channel by registering a
crosswatch.alerts entry point — no change to the core.
Exporters
Span exporters send OpenTelemetry traces to a tracing backend — set via
telemetry.exporters in config (see Configuration)
or crosswatch.ai.configure(exporters=[...]) directly. Unlike alerts, this one
is fully wired: both the CLI and the Python API call into it. Discover
what's available:
from crosswatch import plugins
print(plugins.available(plugins.EXPORTERS))
# ['langfuse', 'otlp', 'phoenix']
type |
Sends to | Config via env var |
|---|---|---|
otlp |
any OTLP/HTTP collector | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
phoenix |
an Arize Phoenix collector | PHOENIX_COLLECTOR_ENDPOINT (default http://localhost:6006/v1/traces) |
langfuse |
Langfuse, via its OTel endpoint | LANGFUSE_HOST (default https://cloud.langfuse.com) |
Third parties can publish their own exporter by registering a
crosswatch.exporters entry point — no change to the core.
5-minute quickstart
1. Describe what to watch in crosswatch.yml:
store: { type: duckdb, path: .crosswatch/crosswatch.duckdb }
telemetry: { exporters: [] } # add 'phoenix' / 'langfuse' / 'otlp'
connectors:
warehouse: { type: duckdb, path: data.duckdb } # or postgres: { dsn: ${POSTGRES_DSN} }
checks:
- { type: volume, connector: warehouse, table: orders, min_rows: 1 }
- { type: freshness, connector: warehouse, table: orders, column: created_at, max_age: 24h }
- { type: null_duplicate, connector: warehouse, table: orders, columns: [email], max_null_fraction: 0.0, key: [id] }
2. Run the checks (writes results to the store; non-zero exit on failure — drop it straight into CI):
crosswatch check -c crosswatch.yml
✓ volume [orders] pass: row_count=1240
✗ freshness [orders] fail: newest created_at = 2026-10-18T09:00:00+00:00
✓ null_duplicate [orders] pass: no nulls/duplicates beyond threshold
2/3 checks passed; 1 error-severity failure(s)
3. See it in the dashboard:
crosswatch serve -c crosswatch.yml # http://127.0.0.1:8000
Or use the Python API
import crosswatch
from crosswatch.connectors.duckdb import DuckDBConnector
from crosswatch.data.checks.freshness import FreshnessCheck
conn = DuckDBConnector(path="data.duckdb")
result = conn.run_check(FreshnessCheck(table="orders", column="created_at", max_age="24h"))
print(result.status, result.message)
cfg = crosswatch.load("crosswatch.yml") # same config, programmatic access
Configuration — YAML, JSON, or Python
The same config (store, telemetry exporters, connectors, checks, evals, alerts)
can be expressed three ways. ${ENV_VAR} references are expanded in all of them.
YAML (crosswatch.yml)
store: { type: duckdb, path: .crosswatch/crosswatch.duckdb }
telemetry: { service_name: shop, exporters: [otlp] } # or [], [phoenix], [langfuse]
connectors:
warehouse: { type: postgres, dsn: ${POSTGRES_DSN} }
events: { type: clickhouse, host: localhost, port: 8123, user: crosswatch, password: ${CH_PW}, database: crosswatch }
checks:
- { type: volume, connector: warehouse, table: orders, min_rows: 1000 }
- { type: freshness, connector: warehouse, table: orders, column: created_at, max_age: 24h }
- { type: null_duplicate, connector: warehouse, table: customers, columns: [email], max_null_fraction: 0.0, key: [id] }
- { type: drift, connector: events, table: events_today, column: latency_ms, reference_table: events_baseline, threshold: 0.2, severity: warn }
crosswatch check -c crosswatch.yml # exits non-zero on any error-severity failure
JSON (crosswatch.json)
JSON is valid input to the same loader (YAML is a JSON superset), so the CLI and
crosswatch.load accept a .json file directly:
{
"store": { "type": "duckdb", "path": ".crosswatch/crosswatch.duckdb" },
"connectors": {
"warehouse": { "type": "mysql", "host": "127.0.0.1", "port": 3306,
"user": "root", "password": "${MYSQL_PW}", "database": "shop" }
},
"checks": [
{ "type": "volume", "connector": "warehouse", "table": "orders", "min_rows": 1000 },
{ "type": "null_duplicate", "connector": "warehouse", "table": "customers",
"columns": ["email"], "max_null_fraction": 0.0, "key": ["id"] }
]
}
crosswatch check -c crosswatch.json
Python
Load either file format, build from a dict, or construct the objects directly:
import json
import crosswatch
from crosswatch.config import Config
from crosswatch.data import run_checks
# (a) load a YAML or JSON file
cfg = crosswatch.load("crosswatch.yml") # == Config.from_yaml("crosswatch.yml" | "crosswatch.json")
# (b) build from a Python dict (e.g. parsed JSON)
cfg = Config.from_dict(json.load(open("crosswatch.json")))
# run everything the config describes
store = cfg.build_store()
connectors = cfg.build_connectors()
checks = cfg.build_checks()
results = run_checks(checks, connectors, check_targets=[c.connector for c in cfg.checks])
store.write_check_results(results)
failed = [r for r in results if not r.ok]
# (c) or skip config entirely — construct a connector + checks by hand
from crosswatch.connectors.duckdb import DuckDBConnector
from crosswatch.data.checks.volume import VolumeCheck
conn = DuckDBConnector(path="data.duckdb")
r = conn.run_check(VolumeCheck(table="orders", min_rows=1000))
print(r.status, r.message, r.samples) # samples pinpoint the offending rows
Examples
dbt — ingest run_results.json
dbt build # produces target/run_results.json
python examples/dbt_quality.py target/run_results.json
Every dbt model/test outcome becomes a crosswatch CheckResult in the same store,
dashboard, and alert path as native checks (see examples/dbt_quality.py).
RAG — trace an LLM pipeline with token/cost
from crosswatch.ai import configure, instrument_anthropic, observe
configure(service_name="rag", exporters=["phoenix"]) # OTLP to local Phoenix
@observe(kind="chain")
def answer(q: str) -> str:
import anthropic
client = instrument_anthropic(anthropic.Anthropic()) # auto-trace every call
resp = client.messages.create(model="claude-opus-4-8", max_tokens=512,
messages=[{"role": "user", "content": q}])
return resp.content[0].text
Each call emits a GenAI-convention span with gen_ai.usage.input_tokens,
output_tokens, and crosswatch.cost_usd. Full example: examples/rag_tracing.py.
MCP — trace tool calls + eval tool selection
from crosswatch.ai.mcp import TracedMCPClient, tool_selection_accuracy, MCPSessionRecord
client = TracedMCPClient(mcp_session) # every call_tool is traced + recorded
await client.call_tool("get_weather", {"city": "Paris"})
# Score recorded sessions against ground-truth expected tools (CI regression gate):
score = tool_selection_accuracy(recorded_sessions, threshold=0.9)
Run crosswatch eval -c crosswatch.yml to gate CI on tool-selection accuracy. Full
example: examples/mcp_eval.py.
Local demo services
docker compose up -d # Postgres + MySQL + ClickHouse + Phoenix
export CROSSWATCH_TEST_PG_DSN=postgresql://crosswatch:crosswatch@localhost:5433/crosswatch
pytest # unit tests run on DuckDB/SQLite; -m integration hits the servers
Running in the cloud (Spark, Dataproc, Airflow, …)
crosswatch is a library + CLI, not a server — it runs wherever Python runs: an Airflow/Dagster/Prefect task, a Dataproc/EMR PySpark job, a Cloud Run or Kubernetes job, or CI. Two things change versus a laptop:
1. Use a shared, durable store (not the local DuckDB file). On ephemeral
cloud workers, point the store at a managed Postgres so every job writes to one
place the dashboard and crosswatch impact can read:
store: { type: postgres, path: ${CROSSWATCH_STORE_DSN} } # e.g. Cloud SQL / RDS
telemetry: { exporters: [otlp] } # spans → your OTel collector
(DuckDB/Parquet = local default; Postgres = shared/cloud; a ClickHouse results store is on the roadmap. Traces always export over OTLP regardless of store.)
2. Checks run on the cluster via the connector. The checks are Ibis expressions, so they execute in the engine you point at — Spark included.
Spark / Dataproc / EMR / Databricks — attach to the SparkSession your job already has (nothing is pulled to the driver except the aggregates):
# inside a Dataproc / EMR PySpark job (or a Databricks notebook)
from crosswatch.connectors.spark import SparkConnector
from crosswatch.data.checks.null_duplicate import NullDuplicateCheck
conn = SparkConnector.from_session(spark) # your SparkSession
r = conn.run_check(NullDuplicateCheck(table="events", columns=["user_id"],
max_null_fraction=0.0, key=["event_id"]))
or declaratively (pip install 'crosswatch[spark]'):
connectors:
lake: { type: spark, master: "local[*]", app_name: crosswatch } # omit master to attach
Cloud warehouses use the same interface — snowflake, bigquery,
databricks connectors (install the matching extra, pass credentials/DSN). A
typical pattern: run crosswatch check / crosswatch impact as a step in your
orchestrator after the load job, and fail the task on non-zero exit.
Verified locally: DuckDB, SQLite, Postgres, MySQL, ClickHouse. Spark and the cloud warehouses are wired to the same Ibis backends but need a live cluster/account to exercise — treat them as integration-tested-by-you.
Architecture at a glance
connectors/ one adapter per source (Ibis): get_schema/profile/run_check/get_query_history
data/ native checks (Ibis exprs) + dbt ingestion (GE/Soda/Evidently providers planned)
ai/ @observe + OpenAI/Anthropic auto-instrumentation (OTel GenAI conventions)
ai/mcp/ trace tool calls + tool-selection / argument / task-success evals
evals/ evaluator plugin API, LLM-as-judge, MCP tool-use evals, CI mode
store/ DuckDB/Parquet default; ClickHouse/Postgres optional; OTLP exporter
alerts/ Slack / webhook channels, dispatch on failure or always
cli + server/ crosswatch check | eval | serve (FastAPI dashboard)
Everything pluggable is discovered via importlib.metadata entry points
(groups: crosswatch.connectors, crosswatch.checks, crosswatch.check_providers,
crosswatch.evaluators, crosswatch.alerts, crosswatch.exporters), so third parties ship
compatible plugins without touching the core.
Known limitations / roadmap
crosswatch is alpha software. One honest list of what's actually implemented vs. scaffolded-but-not-wired, instead of caveats scattered across the doc:
| Area | Status |
|---|---|
Native checks (freshness/volume/schema_change/null_duplicate/drift) |
✅ implemented, tested |
MCP tool-use evals (tool_selection_accuracy, argument_correctness, task_success_rate, run_regression) |
✅ implemented, tested |
LLMJudge evaluator |
✅ implemented, tested — Python API only; not wired into crosswatch eval's config path yet |
Alert channels (webhook/slack) |
✅ implemented, tested — Python API only; alerts: in crosswatch.yml is parsed into config but never dispatched by the CLI yet |
Span exporters (otlp/phoenix/langfuse) |
✅ implemented, wired into both the CLI and the Python API |
| Great Expectations / Soda check providers | ❌ not built — gx/soda extras install the library only, no crosswatch wrapper exists |
| DeepEval / Ragas eval providers | ❌ not built — deepeval/ragas extras install the library only |
Evidently-based drift reports (crosswatch.data.profiling) |
❌ not built — evidently extra installs the library only; the module referenced in drift.py's docstring doesn't exist yet |
| Snowflake / BigQuery / Databricks / Spark connectors | ⚙️ wired to the same Ibis backends as the verified ones, but not exercised against a live cluster/account — see Running in the cloud |
| Email alert channel / threshold+anomaly alert rules | ❌ not built — only webhook/slack channels and a simple failure-or-always filter exist today |
Hit a gap not listed here? Please open an issue.
Development
uv venv && uv pip install -e '.[dev,server,postgres]'
pytest
ruff check src tests
License
Apache-2.0.
Metadata
Release files for crosswatch 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| crosswatch-0.1.1.tar.gz | 458.5 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| crosswatch-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 534.1 kB
Release files / crosswatch-0.1.1.tar.gz
| Download URL | crosswatch-0.1.1.tar.gz |
|---|---|
| Size | 458.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
eaab67e1768f085576fc33174c5eaa424cab9dc46aeca8a6bf937de4c1cf88c4
|
|
BLAKE2b-256 checksum How to use checksums |
709f72cebb5bda95d700f29a304efe4955abc2885bfe7318638e7863798e3142
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logRelease files / crosswatch-0.1.1-py3-none-any.whl
| Download URL | crosswatch-0.1.1-py3-none-any.whl |
|---|---|
| Size | 75.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
630239c99bfb2bdbb210771cd99b7eec41e5874d87c7aa22291689d79f2e949f
|
|
BLAKE2b-256 checksum How to use checksums |
7c05df8d6129497ab202d2dfef1522e3bd5205c77422f1470ae695a5091d2bb7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logSecurity for crosswatch 0.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for crosswatch-0.1.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.