{"id":1816,"date":"2025-10-20T01:26:51","date_gmt":"2025-10-19T22:26:51","guid":{"rendered":"https:\/\/upcloud.com\/global\/us\/resources\/tutorials\/deploying-jaeger-on-upcloud-managed-kubernetes-with-opentelemetry\/"},"modified":"2026-03-09T14:48:47","modified_gmt":"2026-03-09T14:48:47","slug":"deploying-jaeger-on-upcloud-managed-kubernetes-with-opentelemetry","status":"publish","type":"tutorial","link":"https:\/\/upcloud.com\/global\/resources\/tutorials\/deploying-jaeger-on-upcloud-managed-kubernetes-with-opentelemetry\/","title":{"rendered":"Deploying Jaeger on UpCloud Managed Kubernetes with OpenTelemetry!"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">If you\u2019ve already explored our earlier guide,&nbsp;<a href=\"https:\/\/upcloud.com\/global\/blog\/understanding-distributed-tracing-and-why-jaeger-matters\/\">Understanding Distributed Tracing and Why Jaeger Matters<\/a>,&nbsp;you know how tracing helps connect the dots between microservices, latency, and performance in modern systems.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If not, give it a quick read first it breaks down the core concepts and why tracing is essential in modern observability.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In this tutorial, you\u2019ll put that theory into practice by deploying <strong>Jaeger<\/strong>, the open-source tracing system built for cloud-native environments, on <strong><a href=\"https:\/\/upcloud.com\/global\/docs\/products\/managed-kubernetes\/\">UpCloud Managed Kubernetes<\/a><\/strong>. You\u2019ll instrument a simple Node.js application with <strong>OpenTelemetry<\/strong>, send traces to Jaeger, and visualize complete request flows across services.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">By the end, you\u2019ll have a fully functioning tracing pipeline ready to extend, scale, and integrate into your broader observability stack on <strong><a href=\"https:\/\/upcloud.com\/global\/\">UpCloud<\/a><\/strong>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Prerequisites<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">To follow along, you will need to have the following things set up:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>An UpCloud account with access to the Managed Kubernetes service. If you don\u2019t have one yet, <a href=\"https:\/\/signup.upcloud.com\/\">sign up here<\/a>.<\/li>\n\n\n\n<li>upctl to provision and manage the UpCloud Kubernetes cluster and kubectl to interact with it.<\/li>\n\n\n\n<li>Helm installed on your local machine. Helm will be used to deploy Jaeger using the official chart from the <a href=\"https:\/\/github.com\/jaegertracing\/helm-charts\/tree\/v2\" target=\"_blank\" rel=\"noopener\">jaegertracing Helm repository<\/a>.<\/li>\n\n\n\n<li>Node.js and npm set up locally to build the test app that you will instrument<\/li>\n\n\n\n<li>Docker Desktop to build the image of the app, and a Docker Hub account to host it<\/li>\n\n\n\n<li>A basic understanding of Kubernetes concepts like namespaces, services, and port-forwarding.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">With these in place, you\u2019re ready to set up distributed tracing with Jaeger on UpCloud.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Provision a Kubernetes Cluster<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Let\u2019s begin by provisioning an UpCloud Managed Kubernetes cluster connected to a <a href=\"https:\/\/upcloud.com\/global\/docs\/products\/networking\/\">Private Network<\/a>.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\"><strong>Step 1: Create a Private Network<\/strong><\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Before creating your cluster, you\u2019ll need a Private Network in the same zone. Enable DHCP and define your IP range by running the following command:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">upctl network create \\\n  --name jaeger-net \\\n  --zone de-fra1 \\\n  --ip-network address=10.0.1.0\/24,dhcp=true<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\"><strong>Step 2: Provision the Kubernetes Cluster<\/strong><\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">With the network in place, use the upctl CLI to create a three-node cluster:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">upctl k8s create \\\n  --name jaeger-cluster \\\n  --network jaeger-net \\\n  --zone de-fra1 \\\n  --plan \"production-small\" \\\n  --version 1.30 \\\n  --node-group name=monitoring-nodes,count=3,plan=4xCPU-8GB<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For this tutorial, the production\u2011small plan offers a good balance: each node includes sufficient resources to handle Jaeger\u2019s collector and query workloads under typical loads.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\"><strong>Step 3: Configure <\/strong><strong>kubectl<\/strong><\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">Once the cluster is ready, fetch its kubeconfig and configure your kubectl CLI:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">upctl k8s config jaeger-cluster --output yaml --write .\/cluster-config.yaml\nexport KUBECONFIG=.\/cluster-config.yaml<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">You can now verify access to the cluster by running any kubectl get command:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">kubectl get nodes<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Set up Jaeger<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">With your Kubernetes cluster ready, it\u2019s time to deploy Jaeger using the official Helm chart. This setup will install the Jaeger collector, query service, agent, and storage components in a single step, and expose OTLP endpoints for trace ingestion.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, add the Jaeger Helm chart repository to your local Helm setup:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">helm repo add jaegertracing: https:\/\/jaegertracing.github.io\/helm-charts <\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>helm repo update<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You\u2019ll deploy Jaeger into its own dedicated namespace observability, so create it by running the following command:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">kubectl create namespace observability<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now install Jaeger with OTLP HTTP and gRPC endpoints enabled on its collector service:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">helm install jaeger jaegertracing\/jaeger \\\n  --namespace observability \\\n  --set collector.service.otlp.grpc.name=otlp-grpc \\\n  --set collector.service.otlp.grpc.port=4317 \\\n  --set collector.service.otlp.http.name=otlp-http \\\n  --set collector.service.otlp.http.port=4318<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This will deploy Jaeger in the all-in-one mode, suitable for testing and small-scale environments. It exposes both OTLP gRPC and HTTP ports so you can send trace data using OpenTelemetry SDKs and agents.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Wait for some time for the Jaeger resources to come online. After a while, run the following command to forward the Jaeger Query UI to your local machine and test that it works:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">export POD_NAME=$(kubectl get pods --namespace observability -l \\\n  \"app.kubernetes.io\/instance=jaeger,app.kubernetes.io\/component=query\" \\\n  -o jsonpath=\"{.items[0].metadata.name}\")\n\nkubectl port-forward --namespace observability $POD_NAME 8080:16686<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then open your browser and go to:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">http:\/\/127.0.0.1:8080\/<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You should now see the Jaeger dashboard, where traces will begin to appear as soon as your applications start sending data.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image-270-1024x506.png\" alt=\"-\" class=\"wp-image-66737\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\"><strong>Instrumenting Your Applications<\/strong><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">To see the Jaeger setup in action, you need to instrument and deploy an application on the cluster.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For now, you will create a sample Node.js application and instrument it with OpenTelemetry. This app will expose an endpoint that counts characters in an input string and will emit distributed traces throughout its request lifecycle.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To start, create a new directory named nodejs-char-counter and run npm init -y to create a new Node.js project in it. Then, run the following command to install the required dependencies:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">npm i @opentelemetry\/api \\\n@opentelemetry\/sdk-node \\\n@opentelemetry\/auto-instrumentations-node \\\n@opentelemetry\/exporter-trace-otlp-http  \\\nexpress<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This will install the @opentelemetry\/api and @opentelemetry\/sdk-node to set up OpenTelemetry in Node.js environments, @opentelemetry\/auto-instrumentations-node to set up basic auto-instrumentations, and @opentelemetry\/exporter-trace-otlp-http to export the trace data to Jaeger over HTTP.Now you can start building the app. First of all, create a tracing.js file to set up the OpenTelemetry SDK with the OTLP exporter that sends the trace data to Jaeger. Save the following code in it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">\/\/ tracing.js\nconst { NodeSDK } = require('@opentelemetry\/sdk-node');\nconst { OTLPTraceExporter } = require('@opentelemetry\/exporter-trace-otlp-http');\nconst { getNodeAutoInstrumentations } = require('@opentelemetry\/auto-instrumentations-node');\n\nconst otlpExporter = new OTLPTraceExporter({\n  url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || 'http:\/\/jaeger-collector.observability.svc.cluster.local:4318\/v1\/traces',\n});\n\nconst sdk = new NodeSDK({\n  traceExporter: otlpExporter,\n  instrumentations: [getNodeAutoInstrumentations()],\n  serviceName: process.env.SERVICE_NAME || 'nodejs-char-counter',\n  serviceVersion: process.env.SERVICE_VERSION || '1.0.0',\n});\n\nsdk.start();\n\nconsole.log('OpenTelemetry tracing initialized');\n\nprocess.on('SIGTERM', () =&gt; {\n  sdk.shutdown().finally(() =&gt; process.exit(0));\n});\n\nmodule.exports = sdk;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This file ensures that the exporter is configured with the correct Jaeger collector endpoint (http:\/\/jaeger-collector.observability.svc.cluster.local:4318\/v1\/traces), and all outbound HTTP calls, middleware executions, and custom spans are captured and exported to Jaeger.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Next, create a file named charCounter.js. The actual business logic of the character-counting app will live here. It will receive a string, count its characters, and emit custom spans with metadata and events.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">\/\/ charCounter.js\nconst { trace, SpanStatusCode } = require('@opentelemetry\/api');\nconst tracer = trace.getTracer('char-counter-service', '1.0.0');\n\nfunction countCharacters(input) {\n  const span = tracer.startSpan('count_characters', {\n    attributes: {\n      'operation.type': 'character_counting',\n      'input.provided': !!input,\n      'input.type': typeof input\n    }\n  });\n\n  try {\n    if (!input) {\n      span.setAttributes({ 'input.is_empty': true, 'character.count': 0 });\n      span.setStatus({ code: SpanStatusCode.OK, message: 'Empty input processed' });\n      return { characterCount: 0, originalInput: input, inputType: typeof input, isEmpty: true };\n    }\n\n    const stringInput = String(input);\n    const characterCount = stringInput.length;\n\n    span.setAttributes({\n      'character.count': characterCount,\n      'input.length': stringInput.length,\n      'input.is_empty': characterCount === 0,\n      'input.has_spaces': stringInput.includes(' '),\n      'input.has_special_chars': \/[^a-zA-Z0-9\\s]\/.test(stringInput)\n    });\n\n    span.addEvent('character_counting_started', { 'input.preview': stringInput.substring(0, 50) });\n    span.addEvent('character_counting_completed', { 'result.count': characterCount });\n    span.setStatus({ code: SpanStatusCode.OK, message: 'Character counting successful' });\n\n    return { characterCount, originalInput: stringInput, inputType: typeof input, isEmpty: characterCount === 0 };\n\n  } catch (error) {\n    span.recordException(error);\n    span.setStatus({ code: SpanStatusCode.ERROR, message: `Error counting characters: ${error.message}` });\n    throw error;\n  } finally {\n    span.end();\n  }\n}\n\nmodule.exports = { countCharacters };<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The core logic used for character counting is String(input).length. Two cases of input (empty and non-empty have been handled separately in the countCharacters function, with each of them generating different traces.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now, create the app.js file to wire everything together. Save the following contents in it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">\/\/ app.js\nrequire('.\/tracing');\n\nconst express = require('express');\nconst { trace, context, SpanStatusCode } = require('@opentelemetry\/api');\nconst { countCharacters } = require('.\/charCounter');\n\nconst app = express();\nconst port = process.env.PORT || 3000;\n\n\/\/ Get a tracer instance for the main app\nconst tracer = trace.getTracer('express-app', '1.0.0');\n\n\/\/ Middleware for JSON parsing\napp.use(express.json());\n\n\/\/ Health check endpoint\napp.get('\/health', (req, res) =&gt; {\n  const span = trace.getActiveSpan();\n  if (span) {\n    span.setAttributes({\n      'endpoint': '\/health',\n      'http.method': 'GET'\n    });\n  }\n  \n  res.status(200).json({ \n    status: 'healthy', \n    service: 'nodejs-char-counter',\n    timestamp: new Date().toISOString()\n  });\n});\n\n\/\/ Main character counting endpoint\napp.get('\/', (req, res) =&gt; {\n  \/\/ Start a custom span for this endpoint\n  const span = tracer.startSpan('character_count_endpoint', {\n    attributes: {\n      'http.method': 'GET',\n      'http.route': '\/',\n      'endpoint.name': 'character_count'\n    }\n  });\n\n  \/\/ Run the rest of the operation within this span's context\n  context.with(trace.setSpan(context.active(), span), () =&gt; {\n    try {\n      const { input } = req.query;\n      \n      \/\/ Add request attributes to the span\n      span.setAttributes({\n        'request.has_input_param': !!input,\n        'request.query_params_count': Object.keys(req.query).length,\n        'user_agent': req.get('User-Agent') || 'unknown'\n      });\n\n      \/\/ Add event for request received\n      span.addEvent('request_received', {\n        'query_params': JSON.stringify(req.query),\n        'input_provided': !!input\n      });\n\n      \/\/ Call the character counting function (this will create its own span)\n      const result = countCharacters(input);\n\n      \/\/ Add response attributes to the span\n      span.setAttributes({\n        'response.character_count': result.characterCount,\n        'response.input_was_empty': result.isEmpty,\n        'response.status_code': 200\n      });\n\n      \/\/ Add event for successful processing\n      span.addEvent('processing_completed', {\n        'character_count': result.characterCount,\n        'processing_successful': true\n      });\n\n      \/\/ Set success status\n      span.setStatus({ code: SpanStatusCode.OK, message: 'Request processed successfully' });\n\n      \/\/ Return the response\n      res.status(200).json({\n        input: result.originalInput,\n        characterCount: result.characterCount,\n        isEmpty: result.isEmpty,\n        metadata: {\n          inputType: result.inputType,\n          service: 'nodejs-char-counter',\n          timestamp: new Date().toISOString()\n        }\n      });\n\n    } catch (error) {\n      \/\/ Handle any unexpected errors\n      console.error('Error processing request:', error);\n      \n      span.recordException(error);\n      span.setAttributes({\n        'error.type': 'processing_error',\n        'response.status_code': 500\n      });\n      \n      span.setStatus({ \n        code: SpanStatusCode.ERROR, \n        message: `Processing error: ${error.message}` \n      });\n\n      res.status(500).json({\n        error: 'Internal server error',\n        message: 'An error occurred while processing your request'\n      });\n    } finally {\n      \/\/ Always end the span\n      span.end();\n    }\n  });\n});\n\n\/\/ Error handling middleware\napp.use((err, req, res, next) =&gt; {\n  console.error('Unhandled error:', err);\n  \n  const span = trace.getActiveSpan();\n  if (span) {\n    span.recordException(err);\n    span.setAttributes({\n      'error.type': 'unhandled_error',\n      'response.status_code': 500\n    });\n  }\n  \n  res.status(500).json({\n    error: 'Internal Server Error',\n    message: 'An unexpected error occurred'\n  });\n});\n\n\/\/ Start the server\napp.listen(port, () =&gt; {\n  console.log(`Character counter service running on port ${port}`);\n  console.log(`Health check available at: http:\/\/localhost:${port}\/health`);\n  console.log(`Character counting at: http:\/\/localhost:${port}\/?input=your-text-here`);\n});\n\nmodule.exports = app;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This Node.js app uses ExpressJS to create a simple REST server, with the following endpoints exposed:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>\/: The character counter endpoint, which expects a GET request with the input string passed in as a query parameter named input.<\/li>\n\n\n\n<li>\/health: A health check endpoint to know if the service is up and running.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">It also implements an error-handling middleware to catch all unhandled errors from the app and collect their traces for further investigation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Make sure to add a new script start in the package.json file to start the app:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">{\n  \"name\": \"nodejs-char-counter\",\n  \"version\": \"1.0.0\",\n  \"scripts\": {\n    \/\/ Add the following script\n    \"start\": \"node app.js\",\n    \"dev\": \"nodemon app.js\"\n  },\n  \"dependencies\": {\n    \"@opentelemetry\/api\": \"^1.9.0\",\n    \"@opentelemetry\/auto-instrumentations-node\": \"^0.62.1\",\n    \"@opentelemetry\/exporter-trace-otlp-http\": \"^0.203.0\",\n    \"@opentelemetry\/sdk-node\": \"^0.203.0\",\n    \"express\": \"^5.1.0\"\n  },\n  \"devDependencies\": {\n    \"nodemon\": \"^3.0.1\"\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The app is now ready. You can try running npm start to test it locally.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If all looks good, you can then create the following Dockerfile to containerize the app:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">FROM node:24-alpine\n\n# Set working directory\nWORKDIR \/usr\/src\/app\n\n# Copy package files\nCOPY package*.json .\/\n\n# Set environment variables\nENV NODE_ENV=production\n\n# Install dependencies\nRUN npm ci --omit=dev &amp;&amp; npm cache clean --force\n\n# Copy application code\nCOPY . .\n\n# Create non-root user\nRUN addgroup -g 1001 -S nodejs\nRUN adduser -S nodejs -u 1001\n\n# Change ownership of the app directory\nRUN chown -R nodejs:nodejs \/usr\/src\/app\nUSER nodejs\n\n# Expose port\nEXPOSE 3000\n\n# Health check\nHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\\n  CMD node -e \"require('http').get('http:\/\/localhost:3000\/health', (res) =&gt; { \\\n    if (res.statusCode === 200) process.exit(0); else process.exit(1); \\\n  }).on('error', () =&gt; process.exit(1))\"\n\n# Start the application\nCMD [\"npm\", \"start\"]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This setup runs the app as a non-root user, defines liveness health checks, and ensures compact image size using node:24-alpine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">With the Dockerfile in place, build and push the image to Docker Hub by running the following commands, replacing &lt;your-dockerhub-username&gt; with your actual Docker Hub username:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">docker build -t &lt;your-dockerhub-username&gt;\/nodejs-char-counter:latest .\ndocker push &lt;your-dockerhub-username&gt;\/nodejs-char-counter:latest<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><em>If you are using an ARM architecture machine (such as a Mac with an Apple Silicon chip), you will need to use Docker Buildx to ensure that the Docker image is built for linux\/amd64, not ARM:<\/em><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">docker buildx build --platform linux\/amd64 -t &lt;your-dockerhub-username&gt;\/nodejs-char-counter:latest . --push<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Finally, you can deploy the app on your cluster by creating a Deployment and Service resource manifest for it. To do that, save the following in a file named deployment.yaml:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">apiVersion: apps\/v1\nkind: Deployment\nmetadata:\n  name: nodejs-char-counter\n  namespace: default\n  labels:\n    app: nodejs-char-counter\n    version: v1\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: nodejs-char-counter\n  template:\n    metadata:\n      labels:\n        app: nodejs-char-counter\n        version: v1\n    spec:\n      containers:\n      - name: nodejs-char-counter\n        image: &lt;your-dockerhub-username&gt;\/nodejs-char-counter:latest\n        ports:\n        - containerPort: 3000\n          name: http\n        env:\n        - name: SERVICE_NAME\n          value: \"nodejs-char-counter\"\n        - name: SERVICE_VERSION\n          value: \"1.0.0\"\n        - name: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT\n          value: \"http:\/\/jaeger-collector.observability.svc.cluster.local:4318\/v1\/traces\"\n        - name: OTEL_TRACES_EXPORTER\n          value: \"otlp\"\n        - name: OTEL_EXPORTER_OTLP_PROTOCOL\n          value: \"http\/protobuf\"\n        - name: OTEL_RESOURCE_ATTRIBUTES\n          value: \"service.name=nodejs-char-counter,service.version=1.0.0,deployment.environment=production\"\n        - name: OTEL_TRACES_SAMPLER\n          value: \"traceidratio\"\n        - name: OTEL_TRACES_SAMPLER_ARG\n          value: \"1.0\"  # Sample all traces for demo (use 0.1 for 10% in production)\n        resources:\n          requests:\n            memory: \"128Mi\"\n            cpu: \"100m\"\n          limits:\n            memory: \"256Mi\"\n            cpu: \"200m\"\n        livenessProbe:\n          httpGet:\n            path: \/health\n            port: 3000\n          initialDelaySeconds: 30\n          periodSeconds: 10\n          timeoutSeconds: 5\n          failureThreshold: 3\n        readinessProbe:\n          httpGet:\n            path: \/health\n            port: 3000\n          initialDelaySeconds: 5\n          periodSeconds: 5\n          timeoutSeconds: 3\n          failureThreshold: 3\n        securityContext:\n          runAsNonRoot: true\n          runAsUser: 1001\n          allowPrivilegeEscalation: false\n          capabilities:\n            drop:\n            - ALL\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nodejs-char-counter-service\n  namespace: default\n  labels:\n    app: nodejs-char-counter\nspec:\n  selector:\n    app: nodejs-char-counter\n  ports:\n  - port: 80\n    targetPort: 3000\n    protocol: TCP\n    name: http\n  type: ClusterIP<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">It configures resource limits, probes, and the necessary Jaeger environment variables:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>SERVICE_NAME: This name will be attached to the traces exported from the app<\/li>\n\n\n\n<li>SERVICE_VERSION: The version of the app will be attached to the traces as well.<\/li>\n\n\n\n<li>OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: You can override the default Jaeger collector endpoint that you have defined in your Node.js app using this variable, should you ever need to.<\/li>\n\n\n\n<li>OTEL_TRACES_EXPORTER: Configures the OpenTelemetry traces exporter to use OTLP for exports.<\/li>\n\n\n\n<li>OTEL_EXPORTER_OTLP_PROTOCOL: Configures the OpenTelemetry traces exporter to usehttp\/protobuf` for exports<\/li>\n\n\n\n<li>OTEL_RESOURCE_ATTRIBUTES: Adds metadata to the traces.<\/li>\n\n\n\n<li>OTEL_TRACES_SAMPLER: Controls how traces are sampled before they are exported. traceidratio configures the SDK to sample a percentage of spans based on the trace ID. Used with OTEL_TRACES_SAMPLER_ARG to define the percentage of traces retained (1.0 means 100%).<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Now, all you need to do is replace &lt;your-dockerhub-username&gt; in the image name with your DockerHub username and then apply this manifest to your cluster:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">kubectl apply -f deployment.yaml<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Once the app is running, set up port-forwarding to send requests to it from your local machine:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">kubectl port-forward svc\/nodejs-char-counter-service 3000:80<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Send requests by either opening up http:\/\/localhost:3000\/?input=Hello%20UpCloud in the browser or via cURL:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">curl &#8220;http:\/\/localhost:3000\/?input=Hello%20UpCloud&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You can now visit the Jaeger UI at <code>http:\/\/127.0.0.1:8080<\/code> and should see something like this:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image-271-1024x506.png\" alt=\"-\" class=\"wp-image-66742\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">You can drill into spans to inspect attributes, execution flow, and error details across the traced request:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/upcloud.com\/media\/image-272-1024x506.png\" alt=\"-\" class=\"wp-image-66744\" \/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Next Steps and Best Practices<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">While the all-in-one Jaeger setup works well for testing and small workloads, production environments typically require persistent storage and centralized trace management. By default, traces in this setup are held in memory and lost after pod restarts. To retain traces over longer periods, consider backing Jaeger with a durable storage backend like <a href=\"https:\/\/www.jaegertracing.io\/docs\/2.0\/storage\/elasticsearch\/\" target=\"_blank\" rel=\"noopener\">Elasticsearch<\/a> or Apache <a href=\"https:\/\/www.jaegertracing.io\/docs\/2.dev\/storage\/cassandra\/\" target=\"_blank\" rel=\"noopener\">Cassandra<\/a>. These can be configured via Helm values and provide search, aggregation, and high-volume ingestion capabilities.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You\u2019ll also want to fine-tune your sampling strategy. Collecting 100% of all traces can overwhelm your system and storage, especially in high-throughput applications. Jaeger supports probabilistic and tail-based sampling. For most production setups, a traceidratio sampler with a 10%\u201320% sampling rate offers a good balance between observability and performance.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you operate in a multi-cluster setup, consider deploying a centralized Jaeger collector behind a <a href=\"https:\/\/upcloud.com\/global\/docs\/products\/managed-load-balancer\/\">load balancer<\/a> or ingress, and configure each cluster to export spans to that central endpoint. This allows you to consolidate traces across regions, improve correlation between services, and simplify long-term storage management.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><strong>Conclusion<\/strong><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Distributed tracing gives you a powerful lens into the operation of modern applications. With Jaeger deployed on UpCloud Kubernetes and your application instrumented using OpenTelemetry, you now have a complete pipeline to capture, visualize, and analyze traces in real time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Whether you\u2019re debugging slow requests, identifying bottlenecks, or simply gaining a better understanding of service-to-service communication, tracing offers the context that logs and metrics alone can\u2019t provide. As your infrastructure evolves, you can extend this setup with persistent storage backends, advanced sampling, and centralized collectors to support production-scale workloads.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"author":19,"featured_media":0,"comment_status":"open","ping_status":"closed","template":"","community-category":[223,229,238],"class_list":["post-1816","tutorial","type-tutorial","status-publish","hentry"],"acf":[],"_links":{"self":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial\/1816","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial"}],"about":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/types\/tutorial"}],"author":[{"embeddable":true,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/users\/19"}],"replies":[{"embeddable":true,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/comments?post=1816"}],"version-history":[{"count":1,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial\/1816\/revisions"}],"predecessor-version":[{"id":4533,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/tutorial\/1816\/revisions\/4533"}],"wp:attachment":[{"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/media?parent=1816"}],"wp:term":[{"taxonomy":"community-category","embeddable":true,"href":"https:\/\/upcloud.com\/global\/wp-json\/wp\/v2\/community-category?post=1816"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}