Record each run as a span
PHPThe 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.
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);use Slen\Sdk\SlenConfig;
use Slen\Sdk\SlenSdk;
$slen = SlenSdk::create(new SlenConfig(
apiKey: (string) getenv('SLEN_API_KEY'),
spanRecorder: $recorder,
));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.00061590The SDK has these recorders:
Psr3SpanRecorderwrites a span as the messageslen.spanwith its fields as the context, through a PSR-3 logger, at the levelinfounless you name another. An unknown level is refused withInvalidArgumentExceptionwhen the recorder is built.RecordingSpanRecorderkeeps 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.
| Field | Attribute | What it holds |
|---|---|---|
operation | operation | The name of the span — below |
outcome | outcome | ok, refused or error — below |
status | status | The status of the execution, or null when the call raised an error |
durationMs | duration_ms | The time of the call in milliseconds, the repeats of the SDK included; for a wait, the whole wait |
executionId | execution_id | The execution |
operationCode | operation_code | The operation code the run named; for a wait, the one its answer names |
estimated | estimated | What the model calls of the run were estimated to cost, a decimal string |
actual | actual | What they cost, a decimal string, once that is known |
errorCode | error_code | The code of the error, or of the execution that did not complete |
cacheHit | cache_hit | For an execution that has an answer, whether it came from the cache. false is kept as a value |
[
{ "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 isoktoo.refused— Slen said no: any402, a policy or a blocked customer, or a flow that endedrejected. 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 endedfailedoruncertain.
The names of spans
| Name | Written by |
|---|---|
operations.execute | operations.execute(): a run that waits for its answer |
operations.execute_async | operations.executeAsync(): a run sent to the background. Its first answer says queued, and nothing is charged yet |
executions.wait | executions.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
Logging — the journal of each request, beside the spans.
Laravel integration — spans written to the log of a Laravel application.
Testing your integration — test your code without a network.