{"id":1634,"date":"2025-11-10T19:34:01","date_gmt":"2025-11-10T16:34:01","guid":{"rendered":"https:\/\/www.dchost.com\/blog\/tracing-that-feels-like-a-conversation-with-your-app\/"},"modified":"2025-11-10T19:34:01","modified_gmt":"2025-11-10T16:34:01","slug":"tracing-that-feels-like-a-conversation-with-your-app","status":"publish","type":"post","link":"https:\/\/www.dchost.com\/blog\/en\/tracing-that-feels-like-a-conversation-with-your-app\/","title":{"rendered":"Tracing That Feels Like a Conversation With Your App"},"content":{"rendered":"<div class=\"dchost-blog-content-wrapper\"><p>{<br \/>\n  &#8220;title&#8221;: &#8220;OpenTelemetry Made Practical: End\u2011to\u2011End Tracing for PHP\/Laravel and Node.js with Jaeger and Tempo&#8221;,<br \/>\n  &#8220;content&#8221;: &#8220;<\/p>\n<p>It started on a Tuesday afternoon, right when the team swore everything \u201clooked good in logs.\u201d The site wasn\u2019t down, exactly\u2014just excruciatingly slow for a handful of requests. Our Laravel app was fine on its own, the Node.js worker insisted it was innocent, and the external API was apparently returning in record time. Classic he-said-she-said between services. Ever had that moment when you stare at a dashboard and feel like the real story is happening between the charts? That was me, coffee in one hand and a sinking feeling in the other.<\/p>\n<p>That was the week I stopped guessing and wired up OpenTelemetry end to end. Suddenly, the whole request path lit up like an airport runway. You could watch the HTTP call leave Laravel, see it hop into the Node.js service, hit the database, and finally bounce back. The slow part wasn\u2019t where anyone expected. And the fix? A tiny change in how we pooled connections and a tighter timeout policy on one span. Today, I want to show you exactly how to get that X-ray vision\u2014practically, with just enough code to run tomorrow morning. We\u2019ll instrument PHP\/Laravel and Node.js, add the OpenTelemetry Collector, and send traces to Jaeger or Tempo so you can actually see what\u2019s going on.<\/p>\n<p>Here\u2019s the thing: logs tell you \u201cwhat,\u201d metrics tell you \u201chow many,\u201d but traces tell you \u201cwhere it hurts.\u201d Tracing is just following a single request as it journeys through your stack\u2014front door to back office and home again. Each step is a span, and spans nest and chain together into a trace. You get timing, relationships, and enough context to know if a slow query was the root cause or just collateral damage.<\/p>\n<p>OpenTelemetry is the universal translator in this story. It\u2019s vendor-neutral, works across languages, and speaks the same headers your PHP and Node.js services can both understand. Instead of picking a different agent for each runtime, you use one common way to create spans, propagate context, and export data. It\u2019s like switching your team to one group chat instead of juggling six different apps that don\u2019t talk to each other.<\/p>\n<p>And because OpenTelemetry is just about collecting signals, you can choose where to send them. Jaeger and Tempo are two friendly places for traces to live. Jaeger gives you a powerful UI for searching and analyzing. Tempo plays beautifully with Grafana, sliding your traces right next to dashboards and logs. You don\u2019t need to choose one forever\u2014just pick what makes sense now.<\/p>\n<div id=\"toc_container\" class=\"toc_transparent no_bullets\"><p class=\"toc_title\">\u0130&ccedil;indekiler<\/p><ul class=\"toc_list\"><li><a href=\"#A_Simple_Architecture_That_Works_in_Real_Life\"><span class=\"toc_number toc_depth_1\">1<\/span> A Simple Architecture That Works in Real Life<\/a><\/li><li><a href=\"#Laravel_Add_Traces_Without_Breaking_Your_Flow\"><span class=\"toc_number toc_depth_1\">2<\/span> Laravel: Add Traces Without Breaking Your Flow<\/a><ul><li><a href=\"#Install_the_SDK_and_set_the_stage\"><span class=\"toc_number toc_depth_2\">2.1<\/span> Install the SDK and set the stage<\/a><\/li><li><a href=\"#Make_your_spans_helpful_not_noisy\"><span class=\"toc_number toc_depth_2\">2.2<\/span> Make your spans helpful, not noisy<\/a><\/li><li><a href=\"#Propagate_context_in_outbound_calls\"><span class=\"toc_number toc_depth_2\">2.3<\/span> Propagate context in outbound calls<\/a><\/li><li><a href=\"#Correlate_logs_show_the_trace_id_in_every_line\"><span class=\"toc_number toc_depth_2\">2.4<\/span> Correlate logs: show the trace id in every line<\/a><\/li><\/ul><\/li><li><a href=\"#Nodejs_Express_Workers_and_a_Tracer_That_Doesnt_Get_in_Your_Way\"><span class=\"toc_number toc_depth_1\">3<\/span> Node.js: Express, Workers, and a Tracer That Doesn\u2019t Get in Your Way<\/a><ul><li><a href=\"#Install_and_initialize\"><span class=\"toc_number toc_depth_2\">3.1<\/span> Install and initialize<\/a><\/li><li><a href=\"#Manual_spans_for_the_parts_that_matter\"><span class=\"toc_number toc_depth_2\">3.2<\/span> Manual spans for the parts that matter<\/a><\/li><li><a href=\"#Keep_context_when_calling_other_services\"><span class=\"toc_number toc_depth_2\">3.3<\/span> Keep context when calling other services<\/a><\/li><li><a href=\"#Correlate_logs_with_trace_ids\"><span class=\"toc_number toc_depth_2\">3.4<\/span> Correlate logs with trace ids<\/a><\/li><\/ul><\/li><li><a href=\"#The_Collector_Your_Calm_Configurable_Hub\"><span class=\"toc_number toc_depth_1\">4<\/span> The Collector: Your Calm, Configurable Hub<\/a><ul><li><a href=\"#A_minimal_Collector_config_for_Jaeger_and_Tempo\"><span class=\"toc_number toc_depth_2\">4.1<\/span> A minimal Collector config for Jaeger and Tempo<\/a><\/li><li><a href=\"#Quick_Jaeger_and_Tempo_notes\"><span class=\"toc_number toc_depth_2\">4.2<\/span> Quick Jaeger and Tempo notes<\/a><\/li><\/ul><\/li><li><a href=\"#Put_It_All_Together_Click_a_Trace_Find_the_Bottleneck\"><span class=\"toc_number toc_depth_1\">5<\/span> Put It All Together: Click a Trace, Find the Bottleneck<\/a><\/li><li><a href=\"#Practical_Tips_From_the_Field_AKA_The_Stuff_I_Wish_I_Knew_First\"><span class=\"toc_number toc_depth_1\">6<\/span> Practical Tips From the Field (AKA The Stuff I Wish I Knew First)<\/a><\/li><li><a href=\"#Troubleshooting_When_Nothing_Shows_Up_Or_Shows_Up_Wrong\"><span class=\"toc_number toc_depth_1\">7<\/span> Troubleshooting: When Nothing Shows Up (Or Shows Up Wrong)<\/a><\/li><li><a href=\"#A_Tiny_Docker_Compose_to_Get_You_Moving\"><span class=\"toc_number toc_depth_1\">8<\/span> A Tiny Docker Compose to Get You Moving<\/a><\/li><li><a href=\"#Security_and_Privacy_Keep_Traces_Useful_and_Safe\"><span class=\"toc_number toc_depth_1\">9<\/span> Security and Privacy: Keep Traces Useful and Safe<\/a><\/li><li><a href=\"#When_to_Pick_Jaeger_or_Tempo_And_Why_You_Dont_Have_to_Marry_Either\"><span class=\"toc_number toc_depth_1\">10<\/span> When to Pick Jaeger or Tempo (And Why You Don\u2019t Have to Marry Either)<\/a><\/li><li><a href=\"#Wrap-Up_The_Calm_After_the_Trace\"><span class=\"toc_number toc_depth_1\">11<\/span> Wrap-Up: The Calm After the Trace<\/a><\/li><\/ul><\/div>\n<h2 id=\"section-2\"><span id=\"A_Simple_Architecture_That_Works_in_Real_Life\">A Simple Architecture That Works in Real Life<\/span><\/h2>\n<p>Let\u2019s keep the architecture boring in the best way. Your apps (Laravel and Node.js) use OpenTelemetry SDKs to produce spans. Those spans get shipped to an OpenTelemetry Collector. The Collector is the traffic controller: it receives spans, batches them, optionally transforms them, and forwards them to Jaeger or Tempo. Think of it as a hub that decouples what apps send from where data ends up. Swap destinations anytime without redeploying your code.<\/p>\n<p>Two small but mighty ideas make the magic happen. First, <strong>context propagation<\/strong>: a small set of headers (like <code>traceparent<\/code>) that ride along with every request so the trace stays stitched across services. Second, <strong>resource attributes<\/strong>: global tags like <code>service.name<\/code>, <code>deployment.environment<\/code>, and <code>service.version<\/code> that make traces searchable and meaningful. Name things well and your future self will thank you during a late-night incident.<\/p>\n<p>If you want a deeper dive later, the <a href=\"https:\/\/opentelemetry.io\/docs\/\" rel=\"nofollow noopener\" target=\"_blank\">official OpenTelemetry docs<\/a> are a solid companion. But for now, let\u2019s wire this up.<\/p>\n<h2 id=\"section-3\"><span id=\"Laravel_Add_Traces_Without_Breaking_Your_Flow\">Laravel: Add Traces Without Breaking Your Flow<\/span><\/h2>\n<h3><span id=\"Install_the_SDK_and_set_the_stage\">Install the SDK and set the stage<\/span><\/h3>\n<p>In my experience, the most painless path is to start with the PHP OpenTelemetry SDK and export via OTLP to the Collector. I like to enable tracing early in Laravel\u2019s bootstrap so everything\u2014from routing to controllers\u2014can be captured. Versions and package names can evolve, but you\u2019ll look for the core OpenTelemetry PHP packages plus an OTLP exporter.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\"># In your Laravel project\ncomposer require open-telemetry\/opentelemetry\n# If your setup separates exporters\/extensions, you may add (names can evolve):\n# composer require open-telemetry\/exporter-otlp\n<\/code><\/pre>\n<p>Now, initialize a tracer provider early. One practical spot is <code>bootstrap\/app.php<\/code> or a dedicated service provider that runs on boot. The idea is to set the global tracer provider and point it to the Collector via OTLP.<\/p>\n<pre class=\"language-php line-numbers\"><code class=\"language-php\">&lt;?php\n\/\/ app\/Providers\/TelemetryServiceProvider.php (example)\nnamespace App\\Providers;\n\nuse Illuminate\\Support\\ServiceProvider;\nuse OpenTelemetry\\API\\Globals;\nuse OpenTelemetry\\API\\Trace\\TracerProviderInterface;\nuse OpenTelemetry\\SDK\\Trace\\TracerProvider;\nuse OpenTelemetry\\SDK\\Trace\\SpanProcessor\\BatchSpanProcessor;\nuse OpenTelemetry\\SDK\\Trace\\Sampler\\ParentBased;\nuse OpenTelemetry\\SDK\\Trace\\Sampler\\TraceIdRatioBased;\nuse OpenTelemetry\\SDK\\Resource\\ResourceInfo;\nuse OpenTelemetry\\SDK\\Common\\AttributeLimits;\nuse OpenTelemetry\\SemConv\\ResourceAttributes;\nuse OpenTelemetry\\Contrib\\Otlp\\Exporter as OtlpExporter; \/\/ Depending on your install path\n\nclass TelemetryServiceProvider extends ServiceProvider\n{\n    public function register(): void\n    {\n        \/\/ No-op\n    }\n\n    public function boot(): void\n    {\n        $serviceName = env('OTEL_SERVICE_NAME', 'laravel-app');\n        $environment = env('OTEL_ENV', 'development');\n        $version     = env('OTEL_SERVICE_VERSION', '1.0.0');\n\n        $resource = ResourceInfo::create(\n            new AttributeLimits(),\n            [\n                ResourceAttributes::SERVICE_NAME =&gt; $serviceName,\n                ResourceAttributes::DEPLOYMENT_ENVIRONMENT =&gt; $environment,\n                ResourceAttributes::SERVICE_VERSION =&gt; $version,\n            ]\n        );\n\n        $exporter = new OtlpExporter(\n            endpoint: env('OTEL_EXPORTER_OTLP_ENDPOINT', 'http:\/\/otel-collector:4318\/v1\/traces'),\n            \/\/ Some SDKs use separate HTTP\/gRPC config or headers; adjust to your version.\n        );\n\n        $sampler = new ParentBased(new TraceIdRatioBased((float) env('OTEL_TRACES_SAMPLER_ARG', 1.0)));\n        $provider = new TracerProvider(\n            new BatchSpanProcessor($exporter),\n            $resource,\n            $sampler\n        );\n\n        Globals::registerTracerProvider($provider);\n    }\n}\n<\/code><\/pre>\n<p>Don\u2019t forget to register that provider in <code>config\/app.php<\/code> and add environment variables to <code>.env<\/code>:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">OTEL_SERVICE_NAME=laravel-app\nOTEL_SERVICE_VERSION=1.2.3\nOTEL_ENV=production\nOTEL_TRACES_SAMPLER_ARG=0.2\nOTEL_EXPORTER_OTLP_ENDPOINT=http:\/\/otel-collector:4318\/v1\/traces\n<\/code><\/pre>\n<h3><span id=\"Make_your_spans_helpful_not_noisy\">Make your spans helpful, not noisy<\/span><\/h3>\n<p>Automatic instrumentation can capture HTTP server spans and common frameworks, but you still want a few manual spans around business logic that matters. In a controller or service class, wrap the fragile or expensive bit. Keep names human-friendly; future you will skim them at 3 a.m.<\/p>\n<pre class=\"language-php line-numbers\"><code class=\"language-php\">&lt;?php\nuse OpenTelemetry\\API\\Globals;\n\n$tracer = Globals::tracerProvider()-&gt;getTracer('app');\n\n\/\/ Somewhere in a controller action:\n$span = $tracer-&gt;spanBuilder('Checkout: calculate cart totals')-&gt;startSpan();\ntry {\n    \/\/ Your business logic...\n    $total = $cartService-&gt;calculateTotals($cartId);\n    $span-&gt;setAttribute('cart.items.count', count($cartService-&gt;items($cartId)));\n    $span-&gt;setAttribute('currency', 'USD');\n} catch (\\Throwable $e) {\n    $span-&gt;recordException($e);\n    $span-&gt;setStatus(\\OpenTelemetry\\SDK\\Common\\Attribute\\StatusCode::STATUS_ERROR);\n    throw $e;\n} finally {\n    $span-&gt;end();\n}\n<\/code><\/pre>\n<h3><span id=\"Propagate_context_in_outbound_calls\">Propagate context in outbound calls<\/span><\/h3>\n<p>When Laravel calls another service\u2014maybe your Node.js worker\u2014pass the trace context along. Most HTTP clients can be wrapped so the <code>traceparent<\/code> header rides shotgun. If you\u2019re using Guzzle, either use contributed instrumentation or add the header manually by reading the current context.<\/p>\n<pre class=\"language-php line-numbers\"><code class=\"language-php\">&lt;?php\nuse GuzzleHttp\\Client;\nuse OpenTelemetry\\API\\Trace\\Propagation\\TraceContextPropagator;\nuse OpenTelemetry\\Context\\Context;\n\n$client = new Client([\n    'base_uri' =&gt; env('WORKER_BASE_URL', 'http:\/\/node-worker:3000')\n]);\n\n$headers = [];\nTraceContextPropagator::getInstance()-&gt;inject(\n    Context::getCurrent(),\n    $headers,\n    \/\/ injector callback:\n    fn(array &amp;$carrier, string $key, string $value) =&gt; $carrier[$key] = $value\n);\n\n$response = $client-&gt;request('POST', '\/process', [\n    'headers' =&gt; $headers,\n    'json' =&gt; [\n        'order_id' =&gt; $orderId,\n        'user_id'  =&gt; $userId,\n    ],\n    'timeout' =&gt; 2.0,\n]);\n<\/code><\/pre>\n<h3><span id=\"Correlate_logs_show_the_trace_id_in_every_line\">Correlate logs: show the trace id in every line<\/span><\/h3>\n<p>One small change that pays off forever: add <code>trace_id<\/code> to every log. It\u2019s like leaving breadcrumbs from the log to the trace. In Laravel, you can add a Monolog processor that reads the current span context and appends IDs.<\/p>\n<pre class=\"language-php line-numbers\"><code class=\"language-php\">&lt;?php\n\/\/ app\/Logging\/TraceContextProcessor.php\nnamespace App\\Logging;\n\nuse OpenTelemetry\\API\\Trace\\Span;\nuse Monolog\\Processor\\ProcessorInterface;\n\nclass TraceContextProcessor implements ProcessorInterface\n{\n    public function __invoke(array $record): array\n    {\n        $spanContext = Span::getCurrent()-&gt;getContext();\n        if ($spanContext &amp;&amp; $spanContext-&gt;isValid()) {\n            $record['extra']['trace_id'] = $spanContext-&gt;getTraceId();\n            $record['extra']['span_id']  = $spanContext-&gt;getSpanId();\n        }\n        return $record;\n    }\n}\n<\/code><\/pre>\n<p>Register that processor in <code>config\/logging.php<\/code> and you\u2019ll be able to jump from a log line to the exact trace in Jaeger or Tempo. If you also centralize logs, you can wire them together in Grafana. I\u2019ve shown the pattern in <a href=\"https:\/\/www.dchost.com\/blog\/en\/merkezi-loglama-ve-gozlemlenebilirlik-vpste-loki-promtail-grafana-ile-sakin-kalan-bir-zihin\/\">my Loki + Promtail + Grafana playbook for centralized logging<\/a>.<\/p>\n<h2 id=\"section-4\"><span id=\"Nodejs_Express_Workers_and_a_Tracer_That_Doesnt_Get_in_Your_Way\">Node.js: Express, Workers, and a Tracer That Doesn\u2019t Get in Your Way<\/span><\/h2>\n<h3><span id=\"Install_and_initialize\">Install and initialize<\/span><\/h3>\n<p>On the Node.js side, I like using the all-in-one Node SDK with auto-instrumentations. It covers HTTP, Express, common databases, and queue libraries. Then I add a couple of manual spans where the business logic deserves a name.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\"># In your Node.js service\nnpm i @opentelemetry\/sdk-node @opentelemetry\/auto-instrumentations-node \\\n      @opentelemetry\/exporter-trace-otlp-http @opentelemetry\/semantic-conventions\n<\/code><\/pre>\n<p>Create a small <code>tracing.js<\/code> (or <code>tracing.ts<\/code>) that runs before your app. A common pattern is to import it at the top of your <code>server.js<\/code>, or use <code>node -r .\/tracing.js server.js<\/code> so it boots first.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">\/\/ tracing.js\n'use strict';\nconst { NodeSDK } = require('@opentelemetry\/sdk-node');\nconst { getNodeAutoInstrumentations } = require('@opentelemetry\/auto-instrumentations-node');\nconst { Resource } = require('@opentelemetry\/resources');\nconst { SemanticResourceAttributes } = require('@opentelemetry\/semantic-conventions');\nconst { OTLPTraceExporter } = require('@opentelemetry\/exporter-trace-otlp-http');\n\nconst exporter = new OTLPTraceExporter({\n  url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http:\/\/otel-collector:4318\/v1\/traces',\n  headers: {} \/\/ Add if your collector needs auth\n});\n\nconst sdk = new NodeSDK({\n  traceExporter: exporter,\n  resource: new Resource({\n    [SemanticResourceAttributes.SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'node-worker',\n    [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.OTEL_ENV || 'development',\n    [SemanticResourceAttributes.SERVICE_VERSION]: process.env.OTEL_SERVICE_VERSION || '1.0.0',\n  }),\n  instrumentations: [getNodeAutoInstrumentations()],\n  \/\/ Sampler can be set via env: OTEL_TRACES_SAMPLER=parentbased_traceidratio, OTEL_TRACES_SAMPLER_ARG=0.2\n});\n\nsdk.start()\n  .then(() =&gt; console.log('OpenTelemetry initialized for Node.js'))\n  .catch((err) =&gt; console.error('Error initializing OpenTelemetry', err));\n\nprocess.on('SIGTERM', async () =&gt; {\n  try {\n    await sdk.shutdown();\n    console.log('OpenTelemetry shutdown complete');\n  } catch (err) {\n    console.error('Error shutting down OpenTelemetry', err);\n  } finally {\n    process.exit(0);\n  }\n});\n<\/code><\/pre>\n<p>Environment variables keep things flexible:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">OTEL_SERVICE_NAME=node-worker\nOTEL_SERVICE_VERSION=2.4.0\nOTEL_ENV=production\nOTEL_TRACES_SAMPLER=parentbased_traceidratio\nOTEL_TRACES_SAMPLER_ARG=0.2\nOTEL_EXPORTER_OTLP_ENDPOINT=http:\/\/otel-collector:4318\/v1\/traces\n<\/code><\/pre>\n<h3><span id=\"Manual_spans_for_the_parts_that_matter\">Manual spans for the parts that matter<\/span><\/h3>\n<p>Let\u2019s say your Node.js service receives the request from Laravel, does a bit of CPU work, then writes to the database. Wrap the tricky bit with a named span and tag it with the interesting parts (but keep PII out).<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">\/\/ In an Express route handler\nconst { trace } = require('@opentelemetry\/api');\n\napp.post('\/process', async (req, res) =&gt; {\n  const tracer = trace.getTracer('worker');\n  await tracer.startActiveSpan('Process order workflow', async (span) =&gt; {\n    try {\n      span.setAttribute('order.id', String(req.body.order_id));\n      await slowStep();        \/\/ CPU or external call\n      await writeToDb();       \/\/ DB write\n      span.addEvent('order.processed');\n      res.json({ ok: true });\n    } catch (err) {\n      span.recordException(err);\n      span.setStatus({ code: 2, message: 'Error processing order' }); \/\/ STATUS_CODE_ERROR\n      res.status(500).json({ ok: false });\n    } finally {\n      span.end();\n    }\n  });\n});\n<\/code><\/pre>\n<h3><span id=\"Keep_context_when_calling_other_services\">Keep context when calling other services<\/span><\/h3>\n<p>If you call another internal service or a third-party API, make sure you forward the context headers (<code>traceparent<\/code>, and optionally <code>tracestate<\/code>). Most auto-instrumentations add this automatically for Node\u2019s native HTTP, Axios, and friends. If you need to add it yourself, read from the current context and set headers before sending the request.<\/p>\n<h3><span id=\"Correlate_logs_with_trace_ids\">Correlate logs with trace ids<\/span><\/h3>\n<p>Whether you\u2019re a Pino fan or team Winston, add a small hook so every log line carries the active <code>trace_id<\/code> and <code>span_id<\/code>. Then it\u2019s trivial to jump from a log search to the exact trace in Jaeger or Tempo.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">\/\/ With Pino\nconst pino = require('pino');\nconst { context, trace } = require('@opentelemetry\/api');\n\nconst logger = pino();\n\nfunction withTraceBindings(msg, extra = {}) {\n  const span = trace.getSpan(context.active());\n  if (span &amp;&amp; span.spanContext()) {\n    const ctx = span.spanContext();\n    return logger.child({\n      trace_id: ctx.traceId,\n      span_id: ctx.spanId,\n      ...extra\n    }).info(msg);\n  }\n  return logger.info(msg);\n}\n\n\/\/ Usage in code:\nwithTraceBindings('Job started', { job: 'thumbnail' });\n<\/code><\/pre>\n<h2 id=\"section-5\"><span id=\"The_Collector_Your_Calm_Configurable_Hub\">The Collector: Your Calm, Configurable Hub<\/span><\/h2>\n<p>I always reach for the OpenTelemetry Collector because it keeps your code simple and your future options open. Your apps send OTLP to the Collector. The Collector batches, retries, and exports to the backend of your choice. You can tweak sampling, rename attributes, and fan out to multiple destinations if you\u2019re experimenting.<\/p>\n<h3><span id=\"A_minimal_Collector_config_for_Jaeger_and_Tempo\">A minimal Collector config for Jaeger and Tempo<\/span><\/h3>\n<p>Use a single Collector that receives OTLP over HTTP and forwards to both Jaeger and Tempo. In a real environment, you might point to only one or split by environment. Here\u2019s a compact example that you can adapt.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\"># otel-collector-config.yaml\nreceivers:\n  otlp:\n    protocols:\n      http:\n      grpc:\n\nprocessors:\n  batch:\n\nexporters:\n  jaeger:\n    endpoint: jaeger:14250\n    tls:\n      insecure: true\n  otlphttp\/tempo:\n    endpoint: http:\/\/tempo:4318\n    # headers: { 'X-Scope-OrgID': 'tenant-1' } # If using multi-tenant Tempo\n    tls:\n      insecure: true\n\nservice:\n  pipelines:\n    traces:\n      receivers: [otlp]\n      processors: [batch]\n      exporters: [jaeger, otlphttp\/tempo]\n<\/code><\/pre>\n<p>Run the Collector however you like\u2014Docker, systemd, Kubernetes. Then point Laravel and Node at <code>http:\/\/&lt;collector&gt;:4318\/v1\/traces<\/code> (HTTP) or <code>grpc:\/\/&lt;collector&gt;:4317<\/code> if you prefer gRPC. The difference isn\u2019t philosophical; it\u2019s just plumbing. Stick to what\u2019s easiest in your environment.<\/p>\n<h3><span id=\"Quick_Jaeger_and_Tempo_notes\">Quick Jaeger and Tempo notes<\/span><\/h3>\n<p>Jaeger\u2019s all-in-one image is handy for local dev; their <a href=\"https:\/\/www.jaegertracing.io\/docs\/\" rel=\"nofollow noopener\" target=\"_blank\">getting started<\/a> page walks through ports and UI. Tempo is a different kind of beast\u2014schemaless, cheap storage, best friends with Grafana. The <a href=\"https:\/\/grafana.com\/docs\/tempo\/latest\/\" rel=\"nofollow noopener\" target=\"_blank\">Grafana Tempo documentation<\/a> shows how to add Tempo as a data source, then explore traces in Grafana alongside logs and metrics. Don\u2019t overthink it. Start simple, get traces flowing, then decide your longer-term home.<\/p>\n<h2 id=\"section-6\"><span id=\"Put_It_All_Together_Click_a_Trace_Find_the_Bottleneck\">Put It All Together: Click a Trace, Find the Bottleneck<\/span><\/h2>\n<p>Here\u2019s what your first real trace will feel like. You\u2019ll open Jaeger or Grafana, search by <code>service.name<\/code> set to your Laravel app, and click a trace that took longer than it should. The root span is the incoming HTTP request. Beneath it, you\u2019ll see spans for your controller, outbound HTTP to Node, the Node handler, database calls, maybe a cache check. A single long bar will jump out. That\u2019s your bottleneck.<\/p>\n<p>One of my clients had a trace where the Node span looked slow, but the real delay was upstream\u2014Laravel waited too long before calling Node because it was busy assembling a large payload in PHP. We shaved off time by moving that assembly to the Node side, closer to where the work belonged. Without the trace, everyone would\u2019ve stared at the wrong service.<\/p>\n<p>Another time, we saw a pattern: quick requests in the morning, then a gradual slowdown. The traces told us sampling wasn\u2019t the issue; it was connection pooling during a traffic spike. We added a limit to concurrent calls, tightened timeouts, and the mountain flattened. You don\u2019t need a committee to read traces. You just need to look for the chunk of time that doesn\u2019t make sense and ask \u201cwhy there?\u201d<\/p>\n<h2 id=\"section-7\"><span id=\"Practical_Tips_From_the_Field_AKA_The_Stuff_I_Wish_I_Knew_First\">Practical Tips From the Field (AKA The Stuff I Wish I Knew First)<\/span><\/h2>\n<p>Start with high sampling in dev\u2014100% is fine\u2014so you can see everything while you\u2019re wiring it up. In production, dial it down. I like a parent-based sampler with a modest ratio for steady traffic and a way to temporarily bump it during incidents. If you need to capture a particular tenant or endpoint more aggressively, the Collector can help with tail-based sampling in more advanced setups.<\/p>\n<p>Keep your <strong>service names<\/strong> stable and meaningful. Include <code>service.version<\/code> so you can compare releases. Tag <code>deployment.environment<\/code> to quickly filter staging versus production. Use attributes sparingly and thoughtfully\u2014don\u2019t stuff full SQL queries into attributes or put PII where it doesn\u2019t belong.<\/p>\n<p>Propagation is the glue. By default, the SDKs use the W3C Trace Context, which is what you want. If a legacy service speaks B3 headers, you can add a propagator to translate, but try to converge on one standard. Also, make sure your reverse proxy doesn\u2019t strip tracing headers. If you\u2019re behind Nginx or a load balancer, let <code>traceparent<\/code> through.<\/p>\n<p>Don\u2019t ignore time sync. I know it sounds boring, but drift between hosts can make spans look weird. NTP is your quiet friend. The same goes for container restarts and hot reloads\u2014remember to gracefully shut down SDKs so they flush spans on exit. It feels like overkill until the one span you needed never makes it upstream.<\/p>\n<p>For logs, include <code>trace_id<\/code> and <code>span_id<\/code> everywhere you can. When you later stitch logs and traces in Grafana, it feels like turning on the lights in a dark room. If you\u2019re curious how I set that up with Loki and Promtail, I walk through the approach in the logging playbook linked earlier.<\/p>\n<p>Finally, be kind to your future self. Name manual spans after the business action, not the function name. \u201cApply voucher and recompute totals\u201d tells a human what happened. \u201ccalcTotalsV2\u201d will look like static when you\u2019re tired.<\/p>\n<h2 id=\"section-8\"><span id=\"Troubleshooting_When_Nothing_Shows_Up_Or_Shows_Up_Wrong\">Troubleshooting: When Nothing Shows Up (Or Shows Up Wrong)<\/span><\/h2>\n<p>If your traces don\u2019t appear, start at the edges and work inward. From the app side, verify environment variables. If you\u2019re using HTTP, can the app reach <code>collector:4318<\/code>? If you\u2019re using Docker, is the network configured so services can see each other? Firewalls and SELinux can be surprisingly chatty about blocking traffic\u2014listen to them.<\/p>\n<p>Next, look at the Collector logs. Is it receiving? Are batches being sent? If Jaeger or Tempo are not showing the data, try sending only to one exporter to reduce moving parts. Keep the config small. When in doubt, switch to an all-in-one Jaeger in local dev to sanity check data flow.<\/p>\n<p>If spans appear but don\u2019t stitch across services, it\u2019s a propagation issue. Make sure your outbound HTTP in Laravel injects the W3C headers and that Node extracts them (auto-instrumentations usually do). Check your ingress: proxies should not strip <code>traceparent<\/code>. Also, verify clocks\u2014mismatched time can make child spans look older than parents, which is confusing and sometimes leads you to the wrong conclusion.<\/p>\n<p>In PHP-FPM land, remember to reload after installing extensions or changing provider code. In Node.js, make sure your <code>tracing.js<\/code> is required before the rest of your app so auto-instrumentations can patch modules at import time. If you\u2019re using cluster mode, ensure you initialize tracing in each worker.<\/p>\n<h2 id=\"section-9\"><span id=\"A_Tiny_Docker_Compose_to_Get_You_Moving\">A Tiny Docker Compose to Get You Moving<\/span><\/h2>\n<p>If you want something you can run today on a laptop or a throwaway VM, here is a small Compose file that includes the Collector, Jaeger, and Tempo. It\u2019s not a production blueprint\u2014it\u2019s a playground where you can see traces moving.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">version: '3.9'\nservices:\n  otel-collector:\n    image: otel\/opentelemetry-collector:latest\n    command: ['--config=\/etc\/otel-collector-config.yaml']\n    volumes:\n      - .\/otel-collector-config.yaml:\/etc\/otel-collector-config.yaml\n    ports:\n      - '4317:4317'   # gRPC\n      - '4318:4318'   # HTTP\n\n  jaeger:\n    image: jaegertracing\/all-in-one:latest\n    ports:\n      - '16686:16686' # UI\n      - '14250:14250' # gRPC\n\n  tempo:\n    image: grafana\/tempo:latest\n    command: ['-config.file=\/etc\/tempo.yaml']\n    volumes:\n      - .\/tempo.yaml:\/etc\/tempo.yaml\n    ports:\n      - '3200:3200'   # Tempo HTTP API\n      - '4318:4318'   # OTLP HTTP (if you want to send directly to Tempo)\n<\/code><\/pre>\n<p>Point Laravel and Node to <code>http:\/\/localhost:4318\/v1\/traces<\/code>. Then open Jaeger at <code>http:\/\/localhost:16686<\/code> and Tempo (most often via Grafana). If you\u2019ve got Grafana handy, add Tempo as a data source and play with the trace explorer. It\u2019s oddly satisfying to watch spans line up after wiring the parts together.<\/p>\n<h2 id=\"section-10\"><span id=\"Security_and_Privacy_Keep_Traces_Useful_and_Safe\">Security and Privacy: Keep Traces Useful and Safe<\/span><\/h2>\n<p>As you add attributes to spans, avoid PII like emails, card numbers, or anything you wouldn\u2019t want in a support ticket. It\u2019s fine to reference stable IDs that mean something to your team, like <code>user.id<\/code> or <code>order.id<\/code>, as long as they don\u2019t point straight at sensitive information. Keep attribute cardinality reasonable\u2014avoid unique or ever-growing values in attributes that you\u2019ll use for filtering. Save the long stories for logs, and keep traces focused on timing and relationships.<\/p>\n<p>It\u2019s also smart to add a small review step in your PR process: \u201cAre we recording anything sensitive in spans?\u201d You\u2019ll catch things early, and your compliance folks will sleep better. If you want to be extra tidy, add attribute processors in the Collector to drop or rename anything risky before it hits your backend.<\/p>\n<h2 id=\"section-11\"><span id=\"When_to_Pick_Jaeger_or_Tempo_And_Why_You_Dont_Have_to_Marry_Either\">When to Pick Jaeger or Tempo (And Why You Don\u2019t Have to Marry Either)<\/span><\/h2>\n<p>I\u2019ve used both in real projects, sometimes even side by side during a transition. Jaeger has a clean UI that makes it easy to search and analyze traces with no extra ceremony. Tempo shines when you\u2019re already living in Grafana and want traces, logs, and metrics in one place. The good news is that OpenTelemetry doesn\u2019t lock you in. If you start with one and later want the other, the Collector makes that decision a configuration change rather than a code rewrite.<\/p>\n<p>For a quick local dev setup, Jaeger\u2019s all-in-one is a fast win. For a stack that already loves Grafana dashboards and Loki logs, Tempo slips right in. You can\u2019t really go wrong by starting, learning, and then refining.<\/p>\n<h2 id=\"section-12\"><span id=\"Wrap-Up_The_Calm_After_the_Trace\">Wrap-Up: The Calm After the Trace<\/span><\/h2>\n<p>If you\u2019ve ever felt like your services were talking behind your back, tracing is your translator. With Laravel and Node wired to OpenTelemetry, and a Collector steering traffic to Jaeger or Tempo, your stack starts telling the truth in a way you can actually use. The first time you click a trace and see the slow step glowing on screen, you\u2019ll wonder how you ever debugged without it.<\/p>\n<p>My advice: keep it simple at first. Set the SDKs, send OTLP to the Collector, and view traces in one backend. Add manual spans to the parts of your code that make you nervous. Correlate logs with trace IDs so you can pivot between views without losing your place. As you grow more confident, tune sampling and add small guardrails for privacy. The whole point is clarity without drama.<\/p>\n<p>Hope this was helpful. If you wire this up this week and uncover a sneaky bottleneck, I\u2019d love to hear about it. See you in the next post\u2014and may your slowest span be the one you already expect.<\/p>\n<p>&#8220;,<br \/>\n  &#8220;focus_keyword&#8221;: &#8220;OpenTelemetry end-to-end tracing&#8221;,<br \/>\n  &#8220;meta_description&#8221;: &#8220;Practical OpenTelemetry for PHP\/Laravel and Node.js: wire up the Collector, trace requests end to end, and view spans in Jaeger or Tempo to debug faster.&#8221;,<br \/>\n  &#8220;faqs&#8221;: [<br \/>\n    {<br \/>\n      &#8220;question&#8221;: &#8220;Do I really need the OpenTelemetry Collector, or can my apps send traces directly?&#8221;,<br \/>\n      &#8220;answer&#8221;: &#8220;Great question! You can send traces directly, but I like the Collector as a routing hub. It batches, retries, and lets you switch between Jaeger or Tempo without redeploying code. It\u2019s the small piece that keeps the rest flexible.&#8221;<br \/>\n    },<br \/>\n    {<br \/>\n      &#8220;question&#8221;: &#8220;Should I pick Jaeger or Tempo for my first tracing backend?&#8221;,<br \/>\n      &#8220;answer&#8221;: &#8220;Start with whichever gets you seeing traces faster. Jaeger\u2019s all\u2011in\u2011one is super quick for local dev. Tempo is lovely if you\u2019re deep in Grafana already. The best part is you\u2019re not locked in\u2014OpenTelemetry plus the Collector makes switching a config change.&#8221;<br \/>\n    },<br \/>\n    {<br \/>\n      &#8220;question&#8221;: &#8220;How do I avoid leaking sensitive data in traces?&#8221;,<br \/>\n      &#8220;answer&#8221;: &#8220;Keep PII out of span attributes and events. Use stable internal IDs instead of emails or tokens. If in doubt, drop or rename risky attributes in the Collector. A tiny PR checklist\u2014\u201care we recording anything sensitive?\u201d\u2014goes a long way.&#8221;<br \/>\n    }<br \/>\n  ]<br \/>\n}<\/p>\n<\/div>","protected":false},"excerpt":{"rendered":"<p>{ &#8220;title&#8221;: &#8220;OpenTelemetry Made Practical: End\u2011to\u2011End Tracing for PHP\/Laravel and Node.js with Jaeger and Tempo&#8221;, &#8220;content&#8221;: &#8220; It started on a Tuesday afternoon, right when the team swore everything \u201clooked good in logs.\u201d The site wasn\u2019t down, exactly\u2014just excruciatingly slow for a handful of requests. Our Laravel app was fine on its own, the Node.js [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":1635,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[26],"tags":[],"class_list":["post-1634","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-teknoloji"],"_links":{"self":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/1634","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/comments?post=1634"}],"version-history":[{"count":0,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/1634\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media\/1635"}],"wp:attachment":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media?parent=1634"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/categories?post=1634"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/tags?post=1634"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}