Skip to main content

IO MLflow Sink

experimental

Routes xOpat data into MLflow experiments and runs through the IO pipeline. Admin-routable via ENV.client.io.bindings; the record layout is chosen from named templates or a custom mapper. The MLflow credential is held server-side by a proxy alias and never reaches the client.

IDio-mlflow-sink
Version0.1.0
AuthorxOpat
Sourcemodules/io-mlflow-sink

Dependencies

Required configuration

  • mlflow

Additional configuration keys

mlflow

See include.json for details and defaults.

Documentation

io-mlflow-sink

An MLflow-backed sink for the xOpat IO pipeline. Any owner's bundle-* or crud:* capability can be routed here by an admin binding; the sink turns each dispatch into MLflow experiments, runs, metrics, params, tags and artifacts.

When to use

  • You want scores, labels or other per-element state to land in MLflow, where it can be searched, compared and joined with model runs.
  • You want the record layout to be a deployment decision, not a code change.
  • You already run MLflow and want xOpat to be one more producer against it.

If you instead want to talk to MLflow manually — a migration script, the model registry, a custom experiment layout — use the mlflow module's client directly. This module is built on it.

Architecture

Browser xOpat node server MLflow
─────── ───────────────── ──────
IO_PIPELINE
│ binding: ENV.client.io.bindings[owner][cap] = ["mlflow"]

mlflow sink ── mapper(ctx, item) ─► {experiment, run, metrics, tags}


MlFlowClient ──► /proxy/mlflow/api/2.0/mlflow/...
(proxy: │
"mlflow") │ responseProxy:
│ • verifies viewer JWT (verifiers chain)
│ • injects Authorization: Bearer <TOKEN>
│ • forwards to upstream baseUrl

mlflow.yourhost.com

Two halves, deliberately separated:

  • Transportproxy, baseURL, auth, experimentAllow. Deployment config, read from include.json / ENV only. Nothing at runtime can change where data goes.
  • Structure — which experiment, run, metric keys. Chosen by a named template or a registered mapper, resolvable per dispatch, and reachable from the mlflowSink scripting namespace.

The MLflow credential never reaches the browser. It lives in server.secure.proxies.<alias>.headers.Authorization, expanded server-side from an environment variable via the <% VAR %> template syntax. See src/HTTP_CLIENT.md §5–9.

1. Server setup

1a. Declare the proxy alias

In your deployment's config.json:

"server": {
"secure": {
"proxies": {
"mlflow": {
"baseUrl": "https://mlflow.yourhost.com/",
"headers": {
"Authorization": "Bearer <% MLFLOW_TOKEN %>"
},
"auth": {
"enabled": true,
"verifiers": ["jwt"],
"mode": "all",
"jwt": {
"forward": false,
"userClaimHeader": "x-user-sub"
}
}
}
}
}
}
  • baseUrl is the MLflow host root — the /api/2.0/mlflow part comes from the client's baseURL and is joined by the proxy.
  • <% MLFLOW_TOKEN %> is expanded once at core init from process.env.MLFLOW_TOKEN. The literal token never lands in any client-shipped artifact.
  • forward: false strips the viewer's JWT before the upstream call, so MLflow sees only the deployment credential.
  • For an MLflow with no auth (local dev), drop headers and auth.

1b. Provide the token

export MLFLOW_TOKEN=...

Proxy config is core server config — restart the node server after changing it. (Module code itself is rebuilt by the dev watcher; this is the one part that is not.)

2. Client setup

2a. Module defaults — modules/io-mlflow-sink/include.json

Verbose; every tunable field is listed. null placeholders mean "the admin must override, or the hardcoded default applies".

{
"mlflow": {
"proxy": "mlflow",
"baseURL": "/api/2.0/mlflow",
"auth": null,
"template": "slide-scoring",
"experimentTemplate": "xopat-{ownerId}",
"runTemplate": "xopat-{viewerId}",
"identifierTag": "data_id",
"experimentAllow": null,
"artifacts": null
}
}

null values and _-prefixed keys are stripped before merge — they don't shadow upstream layers.

2b. Admin overrides + bindings — ENV.client.io

{
"sinkOverrides": {
"mlflow": {
"experimentTemplate": "pathology-scores",
"experimentAllow": ["pathology-*"],
"template": "slide-scoring",
"auth": {
"contextId": "core",
"types": ["jwt"],
"required": true
}
}
},
"bindings": {
"slide-scoring": {
"crud:score": ["mlflow"],
"bundle-export": ["mlflow"],
"bundle-import": ["mlflow"]
}
}
}

The auth block is forwarded verbatim to HttpClient and refers to the viewer's token for the proxy's verifier chain — not to any MLflow secret. Drop it if the proxy has auth.enabled: false.

2c. Option layering

Composed on every dispatch; latest wins. null values and _-prefixed keys are filtered out.

  1. Hardcoded JS defaults in mlflow-sink.ts (safety net — always present).
  2. Module include.json mlflow block (deployment-tunable defaults).
  3. ENV.client.io.sinkOverrides.mlflow (admin per-deployment values).

A session template choice made via scripting applies last, and only to template.

KeyRequiredLayerDefault
proxyno¹hardcoded"mlflow"
baseURLno¹hardcoded"/api/2.0/mlflow"
authnoadmin overrideunset
templatenohardcoded"slide-scoring"
experimentTemplatenohardcoded"xopat-{ownerId}"
runTemplatenohardcoded"xopat-{viewerId}"
identifierTagnohardcoded"data_id"
experimentAllowno²admin overrideunset (unrestricted)
artifactsnoadmin overrideunset (no artifacts)

¹ At least one of proxy / baseURL must resolve, or accepts() returns false and the sink opts out silently. ² Strongly recommended whenever mappers can be registered at runtime.

Placeholders for experimentTemplate / runTemplate: {ownerId} {ownerUid} {viewerId} {backgroundId} {capabilityId} {xoType} {resourceName} {itemId}. {viewerId} resolves to _global for global-scope bundles; {backgroundId} to _any when the dispatch is not slide-scoped.

3. Templates

A template maps one record (CRUD) or one payload (bundle) into MLflow terms. Pick with template; inspect at runtime with mlflowSink.listTemplates().

TemplateRun layoutRecord lands asUse when
slide-scoringone run per scored slidemetric <scoreKey> + tag <scoreKey>.labelDefault. One current score per slide.
run-per-viewerone run per viewerstepped metric <scoreKey>.<slide>The scoring history matters.
run-per-sessionone run per ownerwrite-once paramAudit log; every scoring event kept.
bundle-artifactone run per viewerwhole bundle as a JSON artifactRound-tripping opaque bundles. Needs artifacts.

A record must carry a numeric value for the three scoring templates; anything else is declined cleanly (skipped, not refused), so a sink shared by several owners ignores records it does not understand.

Custom structure

When no template fits, register a mapper. The mlflowSink scripting namespace exposes this (and asks the user for permission first):

await mlflowSink.registerMapper("by-author", (ctx, item, o) => {
if (typeof item.value !== "number") return null; // decline
return {
experiment: "pathology-scores", // still allowlist-checked
run: {
name: `reviewer-${item.author}`,
identifierTag: { key: "reviewer", value: String(item.author) },
extraTags: [{ key: "source", value: "xopat" }],
},
metrics: [{ key: item.slideId, value: item.value }],
};
});

The same is available programmatically for a deployment plugin:

singletonModule("io-mlflow-sink").registerMapper("by-author", fn);

A mapper shapes structure only. It cannot read or set proxy, baseURL, auth or experimentAllow, and an experiment outside experimentAllow is refused at dispatch with W_MLFLOW_EXPERIMENT_DENIED. Mappers are ordinary functions — never built from strings — so no eval/Function is involved.

4. Behavior

  • create / update / writeBundle: resolve mapper → experiments.ensureruns.getOrCreateRunByTagone runs.logBatch with all metrics, params and tags → artifact uploads if any. Experiment and run ids are cached and the cache is dropped on any refusal.
  • readBundle / read: resolve the experiment without creating it — a missing experiment or run is a clean "no data yet" ({ok: true}), never an error, so hydration on a fresh deployment is silent.
  • query: streams runs.search results, following MLflow page tokens and honoring ctx.meta.signal. Params: { filter, orderBy, maxResults }.
  • delete: removes the mapped tags and writes an xopat.deleted tombstone tag. See non-goals.
  • accepts(ctx): false when neither proxy nor baseURL resolves — the sink opts out cleanly without surfacing a toast.

5. Troubleshooting

CodeMeaning
W_MLFLOW_AUTH401 / 403 — the server-side token was rejected, or the proxy's verifier chain rejected the viewer's JWT. Check the token's validity and the proxy's auth.verifiers.
W_MLFLOW_NOT_FOUND404 — experiment / run / endpoint missing. Check baseURL and the proxy's upstream baseUrl.
W_MLFLOW_EXPERIMENT_DENIEDA mapper targeted an experiment outside experimentAllow. Nothing was written.
W_MLFLOW_MAPPER_INVALIDThe template name does not exist, the mapper threw, or it returned a mapping without an experiment / run identifier tag.
W_MLFLOW_NO_ARTIFACTSThe mapper emitted artifacts but no artifacts block is configured.
W_MLFLOW_HTTP_<status>Other non-2xx (including 502/504 from a misconfigured proxy alias).

All refusals fan out to the standard io:refused event + toast.

Common misconfigurations

  • Browser hits the MLflow host directlyproxy is unset and baseURL is absolute. Only acceptable for a local, unauthenticated MLflow.
  • Bearer <% MLFLOW_TOKEN %> shows up upstream verbatim — the env var was unset at core init, so expansion left the placeholder. Set it and restart.
  • 404 on every call — the proxy baseUrl already includes /api/2.0/mlflow and so does the client baseURL. Keep the API path on the client side only.
  • Scores silently do not appear — the record carries no numeric value, so the template declined it. Check the owner's record shape.

6. Non-goals

  • Deleting history. MLflow metrics and params are append-only. delete removes tags and writes a tombstone; it cannot retract a logged metric. If deletions must be authoritative, use a database sink.
  • The model registry. Out of scope; use the mlflow module's client.
  • Conflict merging. Last write wins; no three-way merge.
  • Being a save trigger. This is a sink, not a flush policy — existing triggers (user save, session export) drive it.