Skip to main content
Documentation

Direct tracking

Log runs directly with the tracking client.

The tracking client is the lowest-level way to get a run to show up on the Hub. Create a client, pick a project, and open a with block — anything inside it is a tracked run.

If you prefer to wrap a whole function instead of a block, see Decorator tracking — it also gives you a hand with device management, keeping outputs organized, and automatic run lineage.

Prerequisites

Before following this guide, make sure you have completed the setup guide to:

  • Create an Embedl Hub account
  • Install the embedl-hub Python library
  • Configure an API key

Creating a client

Import Client, construct it, and pick a project. If the project does not exist yet, it is created automatically.

from embedl_hub.tracking import Client

client = Client()
client.set_project("my-project")

Opening a run

client.start_run(type) returns a context manager. Everything inside the with block is scoped to that run — the run is marked finished when the block exits, or failed if an exception propagates out.

with client.start_run("train"):
    train_model()

The type argument categorizes the run on the Hub. Use a built-in type from RunType for common categories:

from embedl_hub.tracking import RunType

with client.start_run(RunType.COMPILE):
    ...

Or use a custom string for anything domain-specific:

with client.start_run("evaluate"):
    ...

You can also pass name=... to override the display name on the Hub and description=... to record the run’s purpose, setup, context, or conclusions. Descriptions are trimmed and limited to 1,000 characters.

with client.start_run(
    "train",
    description="Baseline training run before quantization",
):
    train_model()

Update the active run’s description while it is open. A blank string clears the description.

with client.start_run("train"):
    client.update_active_run(description="Accuracy improved after tuning")

Logging data

Inside the with block, use the client to record parameters, metrics, and artifacts:

from pathlib import Path

with client.start_run("train"):
    client.log_param("learning_rate", "0.001")
    client.log_metric("accuracy", 0.923)
    client.log_metric("val_loss", 0.081, step=10)
    client.log_artifact(Path("outputs/model.onnx"))

All logged data appears on the Hub run page alongside the run’s status and duration.

Chaining runs

Pass parent_run_id=... to link a run to a previous one. The parent run appears as the predecessor in the Hub’s lineage view.

with client.start_run("prep") as prep:
    ...

with client.start_run("train", parent_run_id=prep.id):
    ...

A parent must be in the same project as the run that follows it. When all you have is a run ID — from a Hub URL, a colleague, or a scheduler log — ask the Hub which project it belongs to before opening the next run there:

project = client.get_run_project(parent_run_id)
client.use_project(project)

with client.start_run("train", parent_run_id=parent_run_id):
    ...

use_project selects the returned project itself, where set_project looks one up by name. Names are unique per owner only, so a project shared with you may share its name with one of your own, and the name would pick one of them.

get_run_project needs no project selected first. Like get_devices, it raises when the Hub cannot be reached rather than handing back nothing, and a run you cannot read is reported as not found.

Finding runs

client.list_runs() returns one page of the current project’s runs, newest first. Filter by the parameters a run logged, by status, or by type; a status or type can be one value or a list:

from embedl_hub.tracking import RunStatus

page = client.list_runs(params={"vertex_ai_job_id": job_id})
recent_failures = client.list_runs(statuses=RunStatus.FAILED, types="training", limit=10)

A run matches params when it logged every listed parameter with exactly that value; names and values are normalized the way log_param stores them, so what you logged is what you can find. types takes the same words as start_run: a built-in name such as "eval" selects that type, and any other label selects the custom runs filed under it. The Hub applies both kinds at once, so built-in types and labels cannot be combined in one call; make one call per kind.

This is how a job launched elsewhere links to the job before it. Each job logs the launcher’s own job ID as a parameter, and the next one looks that run up to name it as its parent:

page = client.list_runs(params={"vertex_ai_job_id": previous_job_id})
parent = page.items[0] if page.items else None  # newest first

with client.start_run("train", parent_run_id=parent.id if parent else None):
    ...

Each page carries a pagination block — total, has_more, and next_offset — so you can tell a truncated result from a complete one and pass offset=page.pagination.next_offset for the next page.

list_runs is a read, so like get_devices it raises when the Hub cannot be reached rather than handing back an empty page. Wrap the call yourself if an outage should be tolerated.

Keeping tracking failures non-fatal

By default a tracking failure — a dropped connection, an expired API key, a rejected run — raises, and takes your script down with it. When tracking is observability rather than business logic, you can opt out of that per block.

Two independent controls, so you choose what is allowed to fail quietly.

A run that cannot fail to start or finish

Pass safe=True to start_run. If the run cannot be created, you get a warning instead of an exception, the with block still runs, and it yields None:

with client.start_run("train", safe=True) as run:
    if run is None:
        print("not tracked — carrying on")
    train_model()

A failure while finishing the run warns too. Exceptions raised by your own code inside the block are never suppressed.

Logging that cannot fail

Wrap the logging calls in client.safe_log(). Inside the scope, a log call that fails warns instead of raising and returns None; a call that succeeds behaves exactly as it does outside the scope and returns what it logged:

with client.safe_log():
    client.log_metric("accuracy", 0.923)   # warns if the Hub is down

Scopes nest, and client.safe_logging tells you whether you are inside one.

Setup calls take the flag directly

set_project and create_run are one-shot setup calls rather than things you do in a loop, so they take safe= directly instead of relying on a scope. Each returns None when it fails safely:

project = client.set_project("my-project", safe=True)
run = client.create_run("train", safe=True)

An invalid run type still raises even with safe=True — that is a mistake in your call, not a Hub problem.

Combining them

safe=True covers only the run lifecycle, and safe_log() only the logging calls — neither implies the other. Use both for a block that survives a total Hub outage:

with client.safe_log():
    with client.start_run("train", safe=True):
        client.log_param("learning_rate", "0.001")
        accuracy = train_model()
        client.log_metric("accuracy", accuracy)

What safe= does not cover

Safe mode — safe= and safe_log() alike — absorbs an unreliable Hub: a dropped connection, an outage, a rejected request. It does not cover mistakes in your own calls, which raise TrackingUsageError so you can fix them:

  • Starting a run while one is already active. Runs do not nest; pass parent_run_id= to record that one run followed another.
  • A run type of RunType.CUSTOM with no label. This one raises ValueError rather than TrackingUsageError, since it is caught while resolving the argument, before the call reaches the Hub at all.
  • log_artifact given a remote artifact without a ctx, a remote path without a device_name, or a device that is not in the context.
  • log_batch given an entry of the wrong length.

TrackingUsageError subclasses RuntimeError, so existing handlers keep working. Note that a missing project or absent active run also raises RuntimeError and does still degrade: those are the downstream consequence of a failure you already chose to tolerate, not a mistake in the call.

A failed run close is an outage, not a mistake, so it never leaves the client stuck: the run is cleared locally either way and the next start_run works normally.

Wrapping a whole command from the shell

The controls above are for code you own. To put a run around something you do not want to edit — a shell script, a Makefile target, a binary — use embedl-hub exec:

embedl-hub exec --type training python train.py --epochs 10
$ embedl-hub exec --type eval ./evaluate.sh

exec creates the run itself and passes it to the command through EMBEDL_HUB_RUN, so embedl-hub log calls inside the command attach to it. Any run inherited from the surrounding environment is dropped first, so a command never logs into someone else’s run.

The command’s exit status becomes the run’s outcome: zero finishes it, non-zero fails it, and a signal kills it. A command that cannot be launched at all reports what a shell would report, so $? means the same with exec in front as without it. Everything after the command is forwarded untouched, and exec exits with the command’s own status (128 + N when signalled). Termination signals sent to exec are passed on to the command, so cancelling it from a scheduler or kill works the same as cancelling the command directly — and a signal that arrives with no command left to pass it to still exits 128 + N, rather than being lost while the run is being closed. These signal statuses are POSIX ones; on Windows a forwarded signal surfaces as an ordinary exit code instead, and the run is completed as failed rather than killed.

Two things outrank the command’s status. A cancellation you sent wins, even when the command had already finished. And without --safe, a command that succeeded but whose run could not be completed exits 1, so a lost run record is not reported as a clean finish; a command that failed on its own terms keeps its own status either way.

Adding --safe applies the same tolerance to exec’s own tracking calls: if the Hub cannot be reached, you get a warning and the command runs untracked, still reporting its real exit status.

What you already know before the work starts can go on the run without waiting for the script to log it:

embedl-hub exec --type training \
    --param lr=0.01 --param epochs=30 \
    --tag dataset=imagenet \
    --link dashboard=https://grafana.example/d/abc \
    --artifact ./config.yaml \
    ./train.sh

Each option is repeatable and mirrors the embedl-hub log command it stands in for, so there is one syntax to learn. They are recorded before the command starts, which means they are on the run even if it crashes on its first line. A malformed option is rejected before the run is created, so a typo leaves nothing behind. There is no --metric: a metric is a measurement the work produces, and before it runs there is nothing to report.

A Python script started this way can log into the run exec already made for it, instead of starting a second one:

from embedl_hub.tracking import Client

client = Client()
client.use_inherited_run()      # the run exec started, and its project

with client.safe_log():         # a Hub outage must not fail the job
    client.log_metric("loss", 0.1)

use_inherited_run() reads EMBEDL_HUB_RUN and EMBEDL_HUB_PROJECT from the environment. It returns None and warns when there is no run to adopt — which is what exec --safe leaves behind after a Hub outage. Use use_run(run_id) to adopt a run you name yourself: with no project selected, the run is looked up by ID and its own project becomes the selection, so a run ID from a Hub URL or a scheduler log is enough. Pass project= to insist on a project by name.

The safe_log() block is what makes the same script work whether or not tracking succeeded. Without it, logging with no run adopted raises, so a job that ran fine under --safe would still fail on its first log_metric — the outage would reach the workload by another route. Inside the block each call warns and returns None instead. Under exec --safe the adoption itself is tolerant too: exec passes that along, so a Hub that goes down between starting the run and adopting it warns rather than raising.

Adopting a run does not take responsibility for finishing it: whoever started the run still decides its outcome, and under exec that is the wrapper, from the command’s exit status.

embedl-hub exec --safe --type training ./train.sh
! Embedl Hub could not set the project; continuing without it. ...

Controlling the warnings

The warnings use their own category, so you can silence them or turn them back into errors without affecting anything else:

import warnings
from embedl_hub.tracking import TrackingFailureWarning

warnings.filterwarnings("ignore", category=TrackingFailureWarning)  # quiet
warnings.filterwarnings("error", category=TrackingFailureWarning)   # strict

When to reach for the decorator

Direct tracking is the minimum viable path — great when you just want a run to show up for an existing block of code. If you also want:

  • A run-scoped local artifact directory to write outputs into — for clear organization and traceability
  • Automatic connection to remote devices over SSH, with per-device artifact directories
  • Automatic lineage when passing results between functions

…then use the decorator instead. It wraps the client with a HubContext that handles all of the above.

Complete example

from pathlib import Path
from embedl_hub.tracking import Client

client = Client()
client.set_project("my-project")

with client.start_run("prep") as prep:
    client.log_param("source", "data/raw.csv")
    clean_data("data/raw.csv", "data/clean.csv")

with client.start_run("train", parent_run_id=prep.id):
    accuracy = train_model("data/clean.csv")
    client.log_metric("accuracy", accuracy)
    client.log_artifact(Path("outputs/model.onnx"))

Both runs show up on the Hub, linked as a lineage chain.