Skip to content

Instantly share code, notes, and snippets.

@chrisguitarguy
Last active July 24, 2024 13:09
Show Gist options
  • Save chrisguitarguy/dfb3383279fd131b809fb50e3073e63a to your computer and use it in GitHub Desktop.
Save chrisguitarguy/dfb3383279fd131b809fb50e3073e63a to your computer and use it in GitHub Desktop.
Open Telemetry + symfony/runtime runners for CLI and HTTP requests
<?php declare(strict_types=1);
namespace Example;
use Throwable;
use OpenTelemetry\API\Instrumentation\CachedInstrumentation;
use OpenTelemetry\API\Trace\SpanInterface;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
use OpenTelemetry\Context\Context;
use OpenTelemetry\Context\ContextInterface;
use OpenTelemetry\SemConv\TraceAttributes;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\SingleCommandApplication;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Runtime\RunnerInterface;
final class OtelCliRunner implements RunnerInterface
{
public const ENV_ENABLED = 'OTEL_PHP_CLI_ENABLED';
public const ENV_ROOT_SPAN = 'OTEL_PHP_CLI_GENERATE_ROOT_SPAN';
public const INSTRUMENTATION_NAME = 'com.pmg.otel.symfony.cli_runner';
private CachedInstrumentation $instrumentation;
private bool $startObservability;
private bool $startRootSpan;
public function __construct(
private SingleCommandApplication|Application $application,
private InputInterface $input,
private OutputInterface $output,
/** @var array<string, mixed> */
array $context = [],
/** @var non-empty-string */
private string $name = 'console',
) {
$this->instrumentation = new CachedInstrumentation(self::INSTRUMENTATION_NAME);
$this->startObservability = filter_var(
$context[self::ENV_ENABLED] ?? false,
FILTER_VALIDATE_BOOLEAN,
);
$this->startRootSpan = filter_var(
$context[self::ENV_ROOT_SPAN] ?? false,
FILTER_VALIDATE_BOOLEAN,
);
}
public function run(): int
{
if ($this->startObservability) {
Observability::start();
}
// if we're not doing a root span we can just let the application run
if (!$this->startRootSpan) {
return $this->application->run($this->input, $this->output);
}
// let exceptions throw so we can record them in traces below
$this->application->setAutoExit(false);
$this->application->setCatchExceptions(false);
$this->application->setCatchExceptions(false);
// XXX symfony5.4
if (method_exists($this->application, 'setCatchErrors')) {
$this->application->setCatchErrors(false);
}
$context = Context::getCurrent();
$span = $this->startRootSpan($context);
$scope = $span->activate();
$exitCode = 0;
try {
$exitCode = $this->application->run($this->input, $this->output);
if ($exitCode !== 0) {
$span->setStatus(
StatusCode::STATUS_ERROR,
'Command exited with non-zero status code: '.$exitCode,
);
}
} catch (Throwable $e) {
$span->recordException($e, [
TraceAttributes::EXCEPTION_ESCAPED => true,
]);
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
throw $e;
} finally {
$scope->detach();
$span->end();
}
return $exitCode;
}
private function startRootSpan(ContextInterface $context) : SpanInterface
{
// so we can attempt to get an accurate command name
$this->input->bind($this->application->getDefinition());
$name = trim(sprintf(
'%s %s',
$this->name,
$this->input->getFirstArgument(),
));
return $this->instrumentation
->tracer()
->spanBuilder($name)
->setSpanKind(SpanKind::KIND_INTERNAL)
->setParent($context)
->startSpan();
}
}
<?php declare(strict_types=1);
namespace Example;
use Throwable;
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Instrumentation\CachedInstrumentation;
use OpenTelemetry\API\Trace\SpanInterface;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
use OpenTelemetry\Context\Context;
use OpenTelemetry\Context\ContextInterface;
use OpenTelemetry\Contrib\Propagation\TraceResponse\TraceResponsePropagator;
use OpenTelemetry\SemConv\TraceAttributes;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\TerminableInterface;
use Symfony\Component\Runtime\RunnerInterface;
use Alli\Platform\Bundle\Observability\Propagation\RequestHeaderPropagationGetter;
use Alli\Platform\Bundle\Observability\Propagation\ResponseHeaderPropagationSetter;
final class OtelHttpRunner implements RunnerInterface
{
public const ENV_ENABLED = 'OTEL_PHP_HTTP_ENABLED';
public const INSTRUMENTATION_NAME = 'com.pmg.otel.symfony.http_runner';
private CachedInstrumentation $instrumentation;
private bool $startObservability;
public function __construct(
private HttpKernelInterface $kernel,
private Request $request,
/**
* @var array<string, mixed>
*/
array $context = [],
) {
$this->instrumentation = new CachedInstrumentation(self::INSTRUMENTATION_NAME);
$this->startObservability = filter_var(
$context[self::ENV_ENABLED] ?? true,
FILTER_VALIDATE_BOOLEAN,
);
}
public function run(): int
{
if ($this->startObservability) {
Observability::start();
}
$context = $this->getContext();
$span = $this->startRootSpan($context);
$scope = Context::storage()->attach($span->storeInContext($context));
$this->request->attributes->set(SpanInterface::class, $span);
try {
return $this->doRun($span, $scope->context());
} catch (Throwable $e) {
$span->recordException($e, [
TraceAttributes::EXCEPTION_ESCAPED => true,
]);
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
throw $e;
} finally {
$scope->detach();
$span->end();
}
}
private function doRun(SpanInterface $parent, ContextInterface $context) : int
{
$response = $this->handleRequest();
$this->updateSpanWithResponse($parent, $response);
$this->injectTraceResponse($response, $context);
$response->send(true);
$this->terminate($response);
return 0;
}
private function handleRequest() : Response
{
$kernelClass = get_class($this->kernel);
$span = $this->instrumentation
->tracer()
->spanBuilder("{$kernelClass}::handle")
->setAttribute(TraceAttributes::CODE_FUNCTION, 'handle')
->setAttribute(TraceAttributes::CODE_NAMESPACE, $kernelClass)
->startSpan();
$scope = $span->activate();
try {
return $this->kernel->handle($this->request);
} finally {
$scope->detach();
$span->end();
}
}
private function updateSpanWithResponse(SpanInterface $span, Response $response) : void
{
$routeName = $this->request->attributes->get('_route', '');
if (is_string($routeName) && '' !== $routeName) {
$span->updateName(sprintf('%s %s', $this->request->getMethod(), $routeName));
$span->setAttribute(TraceAttributes::HTTP_ROUTE, $routeName);
}
if ($response->getStatusCode() >= Response::HTTP_INTERNAL_SERVER_ERROR) {
$span->setStatus(StatusCode::STATUS_ERROR, '5xx response: '.$response->getStatusCode());
}
$span->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $response->getStatusCode());
$span->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $response->getProtocolVersion());
$contentLength = $response->headers->get('Content-Length');
if (null === $contentLength && is_string($response->getContent())) {
$contentLength = strlen($response->getContent());
}
$span->setAttribute(TraceAttributes::HTTP_RESPONSE_BODY_SIZE, $contentLength);
}
private function injectTraceResponse(Response $response, ContextInterface $context) : void
{
$prop = new TraceResponsePropagator();
$prop->inject($response, ResponseHeaderPropagationSetter::instance(), $context);
}
private function terminate(Response $response) : void
{
if (!$this->kernel instanceof TerminableInterface) {
return;
}
$kernelClass = get_class($this->kernel);
$span = $this->instrumentation
->tracer()
->spanBuilder("{$kernelClass}::terminate")
->setAttribute(TraceAttributes::CODE_FUNCTION, 'terminate')
->setAttribute(TraceAttributes::CODE_NAMESPACE, $kernelClass)
->startSpan();
$scope = $span->activate();
try {
$this->kernel->terminate($this->request, $response);
} finally {
$scope->detach();
$span->end();
}
}
private function getContext() : ContextInterface
{
return Globals::propagator()->extract(
$this->request,
RequestHeaderPropagationGetter::instance(),
);
}
private function startRootSpan(ContextInterface $context) : SpanInterface
{
$method = $this->request->getMethod();
assert($method != '');
return $this->instrumentation
->tracer()
->spanBuilder($method)
->setSpanKind(SpanKind::KIND_SERVER)
->setParent($context)
->setAttribute(TraceAttributes::URL_FULL, $this->request->getUri())
->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, $this->request->getMethod())
->setAttribute(TraceAttributes::HTTP_REQUEST_BODY_SIZE, $this->request->headers->get('Content-Length'))
->setAttribute(TraceAttributes::URL_SCHEME, $this->request->getScheme())
->setAttribute(TraceAttributes::URL_PATH, $this->request->getPathInfo())
->setAttribute(TraceAttributes::USER_AGENT_ORIGINAL, $this->request->headers->get('User-Agent'))
->setAttribute(TraceAttributes::SERVER_ADDRESS, $this->request->getHost())
->setAttribute(TraceAttributes::SERVER_PORT, $this->request->getPort())
->startSpan();
}
}
<?php declare(strict_types=1);
namespace Example;
use InvalidArgumentException;
use OpenTelemetry\Context\Propagation\PropagationGetterInterface;
use Symfony\Component\HttpFoundation\Request;
final class RequestHeaderPropagationGetter implements PropagationGetterInterface
{
public static function instance(): self
{
static $instance;
return $instance ??= new self();
}
/**
* {@inheritdoc}
*/
public function keys($carrier): array // @phpstan-ignore missingType.parameter
{
assert(
$carrier instanceof Request,
new InvalidArgumentException('$request must be an instance of '.Request::class)
);
return $carrier->headers->keys();
}
/**
* {@inheritdoc}
*/
public function get($carrier, string $key) : ?string // @phpstan-ignore missingType.parameter
{
assert(
$carrier instanceof Request,
new InvalidArgumentException('$request must be an instance of '.Request::class)
);
return $carrier->headers->get($key);
}
}
<?php declare(strict_types=1);
namespace Example;
use InvalidArgumentException;
use OpenTelemetry\Context\Propagation\PropagationSetterInterface;
use Symfony\Component\HttpFoundation\Response;
final class ResponseHeaderPropagationSetter implements PropagationSetterInterface
{
public static function instance(): self
{
static $instance;
return $instance ??= new self();
}
/**
* {@inheritdoc}
* @return string[]
*/
public function keys($carrier): array // @phpstan-ignore missingType.parameter
{
assert(
$carrier instanceof Response,
new InvalidArgumentException('$response must be an instance of '.Response::class)
);
return $carrier->headers->keys();
}
/**
* {@inheritdoc}
*/
public function set(&$carrier, string $key, string $value): void // @phpstan-ignore missingType.parameter
{
assert(
$carrier instanceof Response,
new InvalidArgumentException('$response must be an instance of '.Response::class)
);
$carrier->headers->set($key, $value);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment