mesh0 / sdk
Official PHP SDK for the mesh0 AI telemetry platform — send logs, traces, and events; query with TQL.
Package info
pkg:composer/mesh0/sdk
Requires
- php: ^8.2
- ext-json: *
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.59
- guzzlehttp/guzzle: ^7.8
- guzzlehttp/psr7: ^2.6
- phpstan/phpstan: ^1.11
- phpstan/phpstan-strict-rules: ^1.6
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- guzzlehttp/guzzle: Recommended PSR-18 HTTP client; auto-discovered when installed.
- symfony/http-client: Alternative PSR-18 HTTP client; also auto-discovered.
README
Official PHP client for the mesh0 AI telemetry platform. Send logs, custom events, and OTLP traces; query them back with TQL.
- PHP 8.2+ with strict types and readonly DTOs
- PSR-3 logger you can drop into Laravel, Symfony, Slim, …
- PSR-18 HTTP client — bring your own (Guzzle, Symfony HTTP, …) or rely on auto-discovery
- Built-in retries for transient failures with exponential backoff + jitter
- Nested-span instrumentation —
Mesh0\Trace\Tracerfor trees of operations (no-code blocks, request handlers, job pipelines) - Low-latency UDS-DGRAM path —
~5µs/callvia the local mesh0 metrics-agent sidecar - Tested at PHPStan level 9
Installation
composer require mesh0/sdk
If you don't already have a PSR-18 client installed, add Guzzle:
composer require guzzlehttp/guzzle
Quick start
use Mesh0\Client; use Mesh0\Event\Event; $mesh0 = Client::create('m0_abcde_xxxxxxxxxxxxxxxxxxxxxxxx'); // Send a single event. The wire shape is intentionally narrow — identity, // time, plus two open bins (`attributes` queryable, `data` opaque). // Anything domain-specific goes inside attributes / data. $mesh0->events->send( Event::now() ->withAttributes([ 'app.id' => 'checkout', 'app.environment' => 'prod', 'span.name' => 'charge.captured', 'user.id' => 'user_42', 'order_id' => 'ord_123', 'amount_usd' => 19.99, ]), );
Or load configuration from the environment (MESH0_API_KEY, MESH0_BASE_URL):
$mesh0 = Client::fromEnv();
Sending logs (PSR-3)
The fastest way to start streaming telemetry to mesh0 is the bundled PSR-3
logger. Plug it into any framework that takes a Psr\Log\LoggerInterface:
$logger = $mesh0->logger(defaults: [ 'app.id' => 'web', 'app.environment' => 'prod', ]); $logger->info('user {user} signed up', ['user' => 'alice', 'plan' => 'pro']); try { chargeCard($order); } catch (\Throwable $e) { $logger->error('charge failed', [ 'exception' => $e, 'order_id' => $order->id, 'user.id' => $order->userId, ]); }
Context keys that map to wire-level event fields are lifted out; everything
else is merged into attributes:
| Context key | Lifted to top-level wire field |
|---|---|
event_id |
event_id |
trace_id |
trace_id |
span_id |
span_id |
parent_span_id |
parent_span_id |
Plus: exception (Throwable) writes error.type and error.message
into attributes. The interpolated message and log.level always land
in attributes. status and duration_ms are no longer special — pass
them as ordinary context keys ('status' => 'error',
'duration_ms' => 142) and they land in attributes like everything
else. Records are buffered in memory and flushed on flush(), when the
buffer fills, and on shutdown.
If you pass a Tracer to
logger(...), log records emitted inside an active span pick up
trace_id / span_id automatically when you don't supply them yourself.
The logger never throws — delivery failures are swallowed so your
request path stays alive. Pass an optional fallback PSR-3 logger if
you want visibility into why telemetry vanished:
$logger = $mesh0->logger( defaults: ['app.id' => 'web'], fallback: $appLogger, // receives flush errors + malformed-input warnings );
Laravel
// config/logging.php 'channels' => [ 'mesh0' => [ 'driver' => 'custom', 'via' => fn () => Mesh0\Client::fromEnv()->logger(defaults: [ 'app.id' => config('app.name'), 'app.environment' => config('app.env'), ]), ], ],
Symfony / Monolog
Add a psr handler pointing at the mesh0 logger service:
# config/services.yaml services: Mesh0\Client: factory: ['Mesh0\Client', 'fromEnv'] Psr\Log\LoggerInterface $mesh0Logger: factory: ['@Mesh0\Client', 'logger'] # Pass defaults via the constructor's $defaults argument arguments: $defaults: app.id: '%env(APP_NAME)%' app.environment: '%kernel.environment%'
Sending events directly
The Event builder is fluent and immutable — every with* call returns a
new builder.
$mesh0->events->send( Event::now() ->withTraceId($traceId) ->withAttributes([ 'app.id' => 'agents', 'app.environment' => 'prod', 'span.name' => 'agent.run', 'duration_ms' => 820, 'status' => 'success', 'gen_ai.system' => 'anthropic', 'gen_ai.request.model' => 'claude-opus-4-7', 'gen_ai.usage.input_tokens' => 1_240, 'gen_ai.usage.output_tokens' => 380, 'gen_ai.usage.cost_usd' => 0.0184, 'tools' => ['search', 'retrieve'], 'workflow' => 'onboarding', ]) // Big payloads (LLM message arrays, raw req/resp) go in `data` — // opaque, not TQL-queryable, only shown on single-event drilldown. ->withData(['messages' => $messages]), ); // Bulk: send up to 5,000 events per HTTP call. Larger batches are split. $mesh0->events->sendMany($events);
OTLP traces
mesh0 accepts OTLP/HTTP JSON at <baseUrl>/v1/traces. Point any
OpenTelemetry exporter at it with the same Bearer token:
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.mesh0.ai OTEL_EXPORTER_OTLP_PROTOCOL=http/json OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer m0_abcde_xxxxxxxxxxxxxxxxxxxxxxxx
The SDK exposes the read side:
$spans = $mesh0->traces->get($traceId);
Metrics (statsd / DogStatsD over UDS-DGRAM)
For high-frequency counters, gauges, and timings — the kind of telemetry
that shouldn't go through the request-blocking HTTPS path — point at a
co-located mesh0 metrics-agent
sidecar over its Unix datagram socket. UDP support was removed in 1.0;
the SDK speaks udg://<path> exclusively.
Set the agent's bind path once via env or Config:
export MESH0_AGENT_SOCKET=/run/mesh0/agent.sock
$metrics = $mesh0->metrics(); // reads MESH0_AGENT_SOCKET / Config::$agentSocketPath $metrics->increment('checkout.charge', tags: ['tier' => 'pro']); $metrics->gauge('queue.depth', 42); $metrics->timing('db.query_ms', 12.4, tags: ['table' => 'orders']); $metrics->histogram('upload.bytes', 8192); // Convenience: time a block; metric is emitted whether $fn returns or throws. $rows = $metrics->time('db.select_ms', fn () => $pdo->query($sql)->fetchAll());
The socket is opened lazily on the first send, so $mesh0->metrics()
does no I/O. Per-call override:
$metrics = $mesh0->metrics(socketPath: '/tmp/mesh0-test.sock', defaultTags: [ 'service' => 'checkout', 'env' => 'prod', ]);
The agent must be configured with a matching MESH0_LISTEN_ADDR
(unix:///run/mesh0/agent.sock). Calling metrics() (or
events()->agent()) without an agentSocketPath set throws
ConfigurationException — there is no UDP loopback fallback.
Failure semantics
Datagram send failures (peer unreachable, agent not running) are
swallowed — the request path never throws on transport. Pass an optional
PSR-3 logger via new AgentMetricSink($path, $log) to surface a single
warning per state transition (open failure, write failure, oversize
drop). The open-failure latch is terminal for the lifetime of the sink —
long-lived workers that need to recover from a transient agent restart
should construct a fresh sink rather than rely on auto-reopen. Malformed
metric names or tags throw ConfigurationException so programmer errors
fail loudly in development rather than silently disappearing.
sampleRate outside (0, 1] is clamped (≤0 drops, ≥1 always emits)
rather than throwing.
Sending events over UDS-DGRAM (low-latency)
For short-lived processes (PHP request handlers, CLI workers) that can't afford an HTTPS roundtrip per event, fire events at the same metrics-agent sidecar as JSON datagrams (~5µs per call):
$agent = $mesh0->events->agent(); // reads MESH0_AGENT_SOCKET / Config::$agentSocketPath $agent->send( Mesh0\Event\Event::now() ->withAttributes([ 'app.id' => 'checkout', 'app.environment' => 'prod', 'span.name' => 'charge.succeeded', 'order_id' => 'ord_123', ]), ); // Bulk loop — the agent batches before forwarding to mesh0. $agent->sendMany([$e1, $e2, $e3]);
The socket is opened lazily on the first send. Datagrams larger than
32KB are dropped with a single warning (pass a PSR-3 logger to observe),
and transport errors are swallowed — send() never throws.
This path is at-most-once: if the local agent is down or the kernel
drops the datagram, the event is gone. For at-least-once durability, use
$mesh0->events->send(...) which POSTs to /v1/events directly.
Instrumenting nested operations (Tracer)
For trees of nested operations — no-code block executions, request → job
pipelines, anything where a parent's wall-clock includes its children —
use Mesh0\Trace\Tracer. It manages a per-execution trace_id and a stack
of span_ids, and emits exactly one event per closed span through any
EventSink (typically the same agent sink shown above):
$tracer = $mesh0->tracer(); // Closure form — exception-safe, auto-pop, recommended: $result = $tracer->span(['span.name' => 'block.if', 'block_id' => 'b_123'], function () use ($tracer) { return $tracer->span(['span.name' => 'block.http_request', 'url' => $url], fn () => $client->get($url)); }); // Manual form — when a closure doesn't fit (e.g. block dispatchers): $h = $tracer->enter(['span.name' => 'block.loop', 'block_id' => 'b_456']); try { // run block... $tracer->exit($h, attributes: ['iterations' => $n]); } catch (\Throwable $e) { $tracer->exit($h, [ 'status' => 'error', 'error.type' => $e::class, 'error.message' => $e->getMessage(), ]); throw $e; }
The Tracer never injects attribute keys for you. By convention (per
the mesh0 data model) callers set attributes["span.name"] and, on the
error path, attributes["status"] / attributes["error.type"] /
attributes["error.message"] — these are normal attribute keys and the
closure form of span() leaves them entirely to you. The Tracer also
no longer auto-stamps a duration; if you want span wall time to be
queryable, write it to attributes["duration_ms"] yourself before
exit (or measure it in the manual form and pass it through).
Each enter/exit pair becomes one independent datagram on the way
out; the metrics-agent forwards them verbatim and ClickHouse reassembles
the trace via trace_id at query time. There is no "session start" or
"session end" — children always close before parents because the parent's
frame is still on the stack while children run.
Long-lived workers (FrankenPHP, RoadRunner, Swoole) must call
$tracer->reset() between requests so trace state doesn't leak across
them. A non-empty stack at reset time logs a warning through the PSR-3
logger you pass to the constructor.
Adopting an incoming trace (W3C traceparent header):
$tracer->startTrace($_SERVER['HTTP_TRACEPARENT'] ?? null); // First enter() of the request now links to the upstream parent span.
Logs that auto-correlate to the active span: pass the tracer when
building the logger and any record emitted inside a span() will pick
up trace_id / span_id automatically when not supplied in the PSR-3
context:
$logger = $mesh0->logger( defaults: ['app.id' => 'no-code-runtime'], tracer: $tracer, ); $tracer->span(['span.name' => 'block.http_request'], function () use ($logger) { $logger->info('calling upstream'); // trace_id / span_id stamped automatically });
Querying
// Only the identity/time TQL builtins resolve at the top level: // `timestamp, project.id, trace.id, span.id, parent_span.id`. Anything // else (status, duration_ms, span.name, gen_ai.*, …) must be exposed via // a per-project alias or promoted column — set those up in the dashboard, // then reference them by their alias name here. $rows = $mesh0->query->run([ 'from' => 'events', 'select' => ['status', 'count()'], 'where' => ['status' => 'error'], 'groupBy' => ['status'], 'orderBy' => [['count()', 'desc']], 'limit' => 25, ]);
Pagination is also available on the events resource:
$page = $mesh0->events->list(limit: 100); foreach ($page['events'] as $row) { /* … */ } // Or stream every event, transparently following cursors: foreach ($mesh0->events->iterate() as $row) { /* … */ }
Control-plane resources
Thin wrappers over the project- and user-scoped admin endpoints. Payloads are passed through as assoc arrays — see the backend route or each method's PHPDoc for accepted fields.
// Alerts (project key, m0_… — POST sends Idempotency-Key automatically). $alerts = $mesh0->alerts->listAlerts(); $channels = $mesh0->alerts->listChannels(); $mesh0->alerts->createAlert([/* AlertInput */]); // PII scrubbers (project key, requires pii:read / pii:write scopes). $rules = $mesh0->piiScrubbers->listScrubbers(); $mesh0->piiScrubbers->createScrubber([ 'name' => 'Credit cards', 'slug' => 'cc', 'kind' => 'regex', 'pattern' => '\d{13,19}', 'replacement' => '[CC]', 'scope' => ['data'], ]); $mesh0->piiScrubbers->setMode('enforce'); // 'enforce' | 'audit' | 'off' // Schema — aliases + promoted (typed) columns // (project key, requires schema:read / schema:write scopes). $mesh0->schema->createAlias('order_id', 'order.id', 'string'); $mesh0->schema->promoteAlias('order_id'); // 202, status: "materializing" $mesh0->schema->cancelPromotion('order_id'); // KILL+DROP while materializing $mesh0->schema->demotePromoted('order_id'); // CH column retained for forensics // Usage read endpoints (project key, requires usage:read scope). $summary = $mesh0->usage->summary(); // current cap period $series = $mesh0->usage->series('2026-01-01', '2026-04-01', 'month'); // User / org / project management (user key, m0u_…). $me = $mesh0->user->me(); $keys = $mesh0->user->listProjectKeys('acme', 'p_1');
POSTs that create resources without server-side Idempotency-Key
middleware (createScrubber, schema createAlias / promoteAlias /
cancelPromotion, /v1/user/* creates) are not retried — a transient
5xx would otherwise risk minting a duplicate or replaying a destructive
KILL+DROP. PATCH / PUT / DELETE retain the default retry policy.
Configuration
use Mesh0\Client; use Mesh0\Config; $mesh0 = new Client(new Config( apiKey: 'm0_abcde_xxxxxxxxxxxxxxxxxxxxxxxx', baseUrl: 'https://api.mesh0.ai', timeout: 10.0, connectTimeout: 5.0, maxRetries: 2, userAgent: 'my-app/1.0', defaultHeaders: ['X-Tenant' => 'acme'], ));
Environment variables
| Variable | Description |
|---|---|
MESH0_API_KEY |
API key (m0_<routing>_<secret>). Required. |
MESH0_BASE_URL |
Override base URL (self-hosted deployments). |
MESH0_AGENT_SOCKET |
Absolute path to the metrics-agent's Unix datagram socket. Required for metrics() / events->agent(). |
Custom HTTP client
Client accepts any PSR-18 client. Bring your own to share connection
pooling, plug in middleware, or run against a fake in tests:
use GuzzleHttp\Client as Guzzle; use GuzzleHttp\Psr7\HttpFactory; $guzzle = new Guzzle(['timeout' => 5]); $factory = new HttpFactory(); $mesh0 = new Client(Config::fromEnv(), $guzzle, $factory, $factory);
Errors
All exceptions extend Mesh0\Exception\Mesh0Exception. The most common
subclasses are:
| Exception | Status | When |
|---|---|---|
AuthenticationException |
401 / 403 | Missing, malformed, or revoked API key. |
BadRequestException |
4xx | Payload rejected by validation. |
NotFoundException |
404 | Resource doesn't exist. |
RateLimitException |
429 | Inspect ->retryAfter. |
ServerException |
5xx | mesh0 internal error; ->errorId set. |
NetworkException |
— | Transport-level failure (DNS, TLS, …). |
ConfigurationException |
— | Invalid Config. |
The transport retries idempotent failures (5xx, 429, transport errors)
up to Config::maxRetries with exponential backoff and jitter; the
Retry-After header is honored when present.
Development
composer install composer test # PHPUnit composer stan # PHPStan level 9 composer cs # PHP-CS-Fixer (PSR-12) composer ci # All of the above
License
MIT — see LICENSE.