Record each run as a span

PHP

The span the SDK writes for each run and each wait — its fields, where it goes, and how it reaches your logs or your traces.

6 min read

A span is the record of one run as a tracing tool sees it: the call, how it ended, the time, the ids and the money — never a prompt or an answer of a model. The SDK writes one for each run and each wait, and hands it to a recorder you give it. Without a recorder the spans are written nowhere.

Give the SDK a recorder

The recorder below writes each span to a log. The logger stands in for the one of your application.

PHP
use Psr\Log\AbstractLogger;
use Slen\Sdk\Observability\Psr3SpanRecorder;

// Stands in for the PSR-3 logger of your application: it prints the fields this page talks about.
$logger = new class extends AbstractLogger {
    public function log($level, string|\Stringable $message, array $context = []): void
    {
        echo "{$message}: {$context['operation']} {$context['outcome']} {$context['status']} {$context['operation_code']} {$context['execution_id']} {$context['estimated']}", PHP_EOL;
    }
};

$recorder = new Psr3SpanRecorder($logger);
PHP
use Slen\Sdk\SlenConfig;
use Slen\Sdk\SlenSdk;

$slen = SlenSdk::create(new SlenConfig(
    apiKey: (string) getenv('SLEN_API_KEY'),
    spanRecorder: $recorder,
));
PHP
use Slen\Sdk\Modules\Operations\Options\ExecuteOptions;

$execution = $slen->operations()->execute(
    'generate_email',
    ['messages' => [['role' => 'user', 'content' => 'Write a short follow-up email to Ada.']]],
    new ExecuteOptions(idempotencyKey: 'order-61:email'),
);

The run writes one span, and the logger prints:

slen.span: operations.execute ok completed generate_email 74438200973594710 0.00061590

The SDK has these recorders:

  • Psr3SpanRecorder writes a span as the message slen.span with its fields as the context, through a PSR-3 logger, at the level info unless you name another. An unknown level is refused with InvalidArgumentException when the recorder is built.

  • RecordingSpanRecorder keeps the spans in memory, for a test.

To send the spans anywhere else, implement SpanRecorderInterface: it has one method, record(). In a Laravel application the config file of the SDK chooses the recorder — Laravel integration.

A recorder is called while your call is still on its way back to you. What it throws is swallowed: a broken recorder never fails a run. Keep record() fast and send the spans elsewhere in the background.

The fields of a span

A span has ten fields, the same in both SDKs and in this order. A log line and an OpenTelemetry span carry them as attributes, under the names of the second column, and leave out a field that is null.

FieldAttributeWhat it holds
operationoperationThe name of the span — below
outcomeoutcomeok, refused or error — below
statusstatusThe status of the execution, or null when the call raised an error
durationMsduration_msThe time of the call in milliseconds, the repeats of the SDK included; for a wait, the whole wait
executionIdexecution_idThe execution
operationCodeoperation_codeThe operation code the run named; for a wait, the one its answer names
estimatedestimatedWhat the model calls of the run were estimated to cost, a decimal string
actualactualWhat they cost, a decimal string, once that is known
errorCodeerror_codeThe code of the error, or of the execution that did not complete
cacheHitcache_hitFor an execution that has an answer, whether it came from the cache. false is kept as a value
JSON
[
  { "operation": "operations.execute", "outcome": "ok", "status": "completed", "duration_ms": 1843, "execution_id": "74438200973594710", "operation_code": "generate_email", "estimated": "0.00061590", "cache_hit": false },
  { "operation": "operations.execute", "outcome": "refused", "duration_ms": 96, "operation_code": "generate_email", "error_code": "budget_exceeded" },
  { "operation": "executions.wait", "outcome": "error", "status": "failed", "duration_ms": 30512, "execution_id": "74438200973594800", "operation_code": "summarize_report", "error_code": "flow_runtime_error", "cache_hit": false }
]

No field can hold what was said to a model. That is a rule of the type: a span has no field for a message, a prompt or an answer.

How a run ended

  • ok — the run completed, or is not over yet. A wait that ran out of time on an execution that is not over is ok too.

  • refused — Slen said no: any 402, a policy or a blocked customer, or a flow that ended rejected. A decision, not a breakage.

  • error — anything else: the network, a provider, a request that is wrong, a key that was refused, the rate limit of a key — and an execution that ended failed or uncertain.

The names of spans

NameWritten by
operations.executeoperations.execute(): a run that waits for its answer
operations.execute_asyncoperations.executeAsync(): a run sent to the background. Its first answer says queued, and nothing is charged yet
executions.waitexecutions.wait(): one span when the wait ends, however many times it read the status

No other call writes a span: not a read, not a call of the customers, not a webhook.

Count the cost of an execution

One execution can leave several spans: the run, each wait on it, each repeat of its idempotency key. Each of them repeats the estimated and actual the execution had when the span was written. Added up, they overstate it. Take the cost once for each execution_id, from its latest span.

Send spans over OTLP

The PHP SDK has no OTLP exporter. Implement SpanRecorderInterface over the client of your tracing tool, or write the spans to a log with Psr3SpanRecorder and let your log pipeline carry them.

Before a process exits

The recorders of the PHP SDK write each span at once, so there is nothing to send before the process exits.

Where to go next