Multiple Workflows in One Codebase

One process, many independent features — give each its own workflow so they show up as distinct, filterable workflows in the dashboard.

Most real services do more than one thing. A single deployment might run a chat copilot, a summarizer, a nightly report generator, and a background evaluation job — each an independent workflow with its own inputs, its own shape, and its own success criteria. You want them to appear in the dashboard as separate workflows you can filter and compare, not as one undifferentiated blob.

This is not the same as calling init() several times. init() configures the SDK once per process and sets a single, process-wide workflow label; calling it again with a different name is a no-op (see Workflow Name). The way to get N workflows out of one codebase is to declare each workflow at its entry point — the place where that feature's work first begins — by opening a named WORKFLOW root there.

Note

This pattern is language-agnostic. The mechanism — a named WORKFLOW root opened at each feature's entry point, optionally with a neatlogs.workflow_name override — is the same in Python, TypeScript, and Go. Only the syntax differs.

The two levels

There are two independent ways a codebase splits into workflows, and a large service uses both:

  1. Separate process / boot path → separate init(). Two things that boot independently (an HTTP server and a mounted sub-app, a web server and a standalone worker) each call init() with their own workflow_name. Different process = different init() = different workflow. This only works because they're separate boot paths — you cannot re-init() within one process to switch names.

  2. One process, many features → one init(), a named root per feature. Inside a single process, each independent feature opens its own WORKFLOW root where that feature's call first starts (the request handler, the job function, the consumer callback). This is the common case and the focus of this page.

Name vs. workflow: two different dashboard dimensions

A trace root carries two things that are easy to conflate:

What it isSet by
Root span nameThe title of an individual trace (e.g. "chat turn 8f2a")The name you pass when opening the root
WorkflowThe group a trace belongs to — the dashboard's Workflow column, filter, and analyticsThe neatlogs.workflow_name label (process default from init(), or a per-root override)
Warning

Naming the root span is not enough to create a distinct workflow. The span name is the trace's title; the Workflow dimension is driven by the neatlogs.workflow_name label. If every feature just opens a differently-named root but never overrides the workflow label, all of them still roll up under the single init() workflow name. To make a feature its own filterable workflow, set the per-root neatlogs.workflow_name override (shown below). A common convention is to use the same human-readable string for both — the root name and the workflow override — so the trace title and the Workflow column agree.

Declare a workflow at its entry point

Open the root at the first line of the feature's work — the request handler, the background task, the queue-consumer callback — so everything that feature does nests underneath it. Set the workflow label there too.

Open a WORKFLOW root with trace() (or @span(kind="WORKFLOW")) and stamp the label on that root. neatlogs.workflow_name isn't a valid Python keyword, so set it with set_attribute on the root span:

import neatlogs
from opentelemetry import trace as otel_trace

neatlogs.init(api_key=..., workflow_name="my-service")  # process-wide default

@app.post("/copilot/chat")
async def copilot_chat(req):
    with neatlogs.trace("Copilot chat", kind="WORKFLOW") as root:
        # This override is what makes "Copilot chat" its own workflow in the
        # dashboard — distinct from other features in the same process.
        root.set_attribute("neatlogs.workflow_name", "Copilot chat")
        return await run_copilot(req)

@app.post("/reports/generate")
async def generate_report(req):
    with neatlogs.trace("Report generation", kind="WORKFLOW") as root:
        root.set_attribute("neatlogs.workflow_name", "Report generation")
        return await run_report(req)

A tiny helper keeps the two-line pattern from repeating across every entry point:

from contextlib import contextmanager

@contextmanager
def workflow(name: str):
    with neatlogs.trace(name, kind="WORKFLOW") as root:
        root.set_attribute("neatlogs.workflow_name", name)
        yield root

# usage
with workflow("Copilot chat"):
    ...

Open a WORKFLOW root with trace() and set the per-root label via attributes (the dot-form neatlogs.workflow.name, which the dashboard reads as the workflow):

import { init, trace } from 'neatlogs';

await init({ apiKey: '...', workflowName: 'my-service' }); // process-wide default

app.post('/copilot/chat', async (req, res) => {
  await trace(
    { name: 'Copilot chat', kind: 'WORKFLOW', attributes: { 'neatlogs.workflow.name': 'Copilot chat' } },
    async () => runCopilot(req),
  );
});

app.post('/reports/generate', async (req, res) => {
  await trace(
    { name: 'Report generation', kind: 'WORKFLOW', attributes: { 'neatlogs.workflow.name': 'Report generation' } },
    async () => runReport(req),
  );
});

You can also set it inside the callback — trace() passes the root Span: span.setAttribute('neatlogs.workflow.name', 'Copilot chat'). Either way, the process-global neatlogs.workflow_name from init() is not overwritten; the per-root neatlogs.workflow.name is what distinguishes the trace.

Open a WORKFLOW root with Trace (its name is the root span name). Trace takes no options, so to set a distinct workflow label use StartSpan with the neatlogs.workflow.name attribute, or SetAttributes on the root:

import (
    "go.opentelemetry.io/otel/attribute"
    "github.com/neatlogs/neatlogs-go"
)

shutdown, _ := neatlogs.Init(ctx, neatlogs.Config{APIKey: "...", WorkflowName: "my-service"})
defer shutdown(ctx)

func copilotChat(ctx context.Context, req Request) error {
    ctx, span, end := neatlogs.StartSpan(ctx, "Copilot chat", "workflow",
        attribute.String("neatlogs.workflow.name", "Copilot chat"))
    defer end()
    _ = span
    return runCopilot(ctx, req)
}

func generateReport(ctx context.Context, req Request) error {
    ctx, _, end := neatlogs.StartSpan(ctx, "Report generation", "workflow",
        attribute.String("neatlogs.workflow.name", "Report generation"))
    defer end()
    return runReport(ctx, req)
}

neatlogs.Trace(ctx, name) is the shorthand when you only need a named root and are happy for it to roll up under the Init workflow name. Use StartSpan(..., "workflow", ...) when you need the per-root workflow label.

A worked example: the Neatlogs AI service

Neatlogs' own AI service is one FastAPI process that runs a dozen independent features. It shows the pattern end to end:

  • One init() at import time sets a coarse process label: workflow_name="AI Service". This is the fallback for anything that doesn't name itself.
  • Each feature opens its own WORKFLOW root at its request handler and overrides the label. Copilot chat, the trace summarizer ("Neat Agent"), the form builder, suggestion generation, and the judge each declare themselves at the top of their handler — so they land in the dashboard as separate workflows even though they share a process.
  • A separately-booted sub-app runs its own init(). The mounted harness sub-app boots on a different path and calls init(workflow_name="Neatlogs Harness") — level 1 (separate boot path → separate workflow).

The service centralizes the override in one helper so every entry point stays a single call:

# Called at the start of each feature's WORKFLOW root:
bind_tenant_context(workflow_name="Copilot chat", ...)
# → span.set_attribute("neatlogs.workflow_name", "Copilot chat") on the root,
#   plus tenant/session attributes. The backend prefers this per-root value
#   over the process-wide "AI Service" default for the trace's Workflow column.

Features that only open a named root (e.g. the judge, trace("Judge run", kind="WORKFLOW")) but skip the override still roll up under "AI Service" — a reminder that the override, not the span name, is what carves out a distinct workflow.

Choosing workflow boundaries

Treat each of these as its own workflow:

  • Distinct product features — a chat copilot, a summarizer, an autocomplete, a report generator. Different users, different shapes, different metrics.
  • Distinct trigger types for the same logic — the synchronous API path vs. the background/queue path, if you want to analyze them separately.
  • Distinct jobs in a worker — each scheduled or event-driven job function.

Keep it one workflow per feature area, not per request and not per code file. Use a stable, human-readable label ("Copilot chat", "Report generation") — environment and version belong in tags, and the per-user/per-conversation dimension belongs in sessions and end-user identity, not in the workflow name.

See also

  • Workflow Name — the process-wide default set on init().
  • Sessions · End-User Identity — the per-conversation and per-user dimensions, orthogonal to the workflow.
  • Tags — environment / version labels that cut across workflows.
  • Distributed Tracing — when a single workflow spans multiple processes and you want them in one trace.

On this page

Ask Neatlogs AI

Answers from the docs

How can I help?

Ask anything about instrumenting, tracing, or the Neatlogs dashboard.