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-hubPython 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.CUSTOMwith no label. This one raisesValueErrorrather thanTrackingUsageError, since it is caught while resolving the argument, before the call reaches the Hub at all. log_artifactgiven a remote artifact without actx, a remote path without adevice_name, or a device that is not in the context.log_batchgiven 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.