{"slug": "build-a-dinosaur-runner-game-with-deno-pt-6", "title": "Build a dinosaur runner game with Deno, pt. 6", "summary": "This article is the sixth and final part of a series on building a browser-based dinosaur runner game with Deno. It focuses on implementing observability by adding structured JSON logging and custom OpenTelemetry traces to monitor API performance and game events. The guide explains how to use Deno Deploy's built-in dashboards for logs, traces, and metrics without requiring external monitoring tools.", "body_md": "# Build a dinosaur runner game with Deno, pt. 6\n\nThis series of blog posts will guide you through building a simple browser-based dinosaur runner game using Deno.\n\n[Setup a basic project][Game loop, canvas, and controls][Obstacles and collision detection][Databases and global leaderboards][Player profiles and customization]- Observability, metrics, and alerting\n\nObservability, Metrics, and Alerting\n\nIn Stage 5, we gave players an identity: a name, a dino colour, a background theme, and a difficulty level, all persisted in PostgreSQL and loaded back on every visit. The game is now feature-complete. But shipping features is only half the story. The other half is understanding what’s happening once real players arrive.\n\nAre the API routes fast enough? Is the leaderboard staying healthy? Is there a\nspike in errors after a deployment? You can’t answer those questions with\n`console.log(\"Server is running!\")`\n\n. In this final stage, we’ll wire up an\nobservability layer that makes every question answerable, leaning on Deno\nDeploy’s built-in logs, traces, and metrics dashboards so you don’t need to\nreach for a separate monitoring product to get started.\n\nKeep reading and build along or\n[view the entire source here](https://github.com/thisisjofrank/game-tutorial).\n\nWhat you’ll build\n\nBy the end of this post you will have:\n\n**Structured logs**: every HTTP request emits a JSON log line, and key game events (score submissions, customization saves) emit their own structured events that you can search and filter in the Logs dashboard.**Custom traces**: key operations (score submission, leaderboard fetch, loading player settings) each get their own span, so you can see exactly where time is spent inside a request.\n\nBoth are visible in Deno Deploy’s built-in dashboards alongside the platform’s automatic metrics, no extra infrastructure required.\n\nThe three pillars of observability\n\nObservability is the ability to understand what your system is doing from the outside, by examining the data it produces. That data typically comes in three forms:\n\n| Pillar | What it answers |\n|---|---|\nLogs |\nWhat happened, and when? |\nTraces |\nWhere did the time go? |\nMetrics |\nHow is the system trending over time? |\n\nDeno Deploy provides a dashboard for all three. Logs and traces support custom instrumentation, you can add your own structured log events and custom spans. The Metrics dashboard shows platform-level data that Deno Deploy captures automatically, which we’ll look at in its own section.\n\nSetting up the telemetry module\n\nWe need one package: the\n[OpenTelemetry API](https://www.npmjs.com/package/@opentelemetry/api). On Deno\nDeploy, the runtime wires up the SDK and exporter for you, no other\nconfiguration required. The API package gives us the interfaces we call in our\ncode; the platform handles where the data goes.\n\nAdd it to `deno.json`\n\n:\n\n```\n{\n  \"imports\": {\n    \"@oak/oak\": \"jsr:@oak/oak@17\",\n    \"@opentelemetry/api\": \"npm:@opentelemetry/api@^1.9.0\",\n    \"npm:pg\": \"npm:pg@^8.11.0\"\n  }\n}\n```\n\nCreate `src/telemetry.ts`\n\nto hold the shared tracer instance:\n\n``` js\nimport { SpanStatusCode, trace } from \"@opentelemetry/api\";\n\n// Shared tracer - used to create custom spans throughout the server\nexport const tracer = trace.getTracer(\"dino-game\", \"1.0.0\");\n\nexport { SpanStatusCode };\n```\n\nKeeping this in one place means every file uses the same tracer name and version, which groups all your custom spans together in the dashboard.\n\nLogs\n\nEvery `console.log`\n\ncall you make is captured by Deno Deploy and shown in the\n**Logs** tab of your app’s dashboard. But plain-text logs are hard to filter.\nThe upgrade is to emit structured JSON, a single parseable object per event that\nyou can search by any field.\n\nCreate a logging middleware in `src/middleware/logging.ts`\n\n:\n\n``` python\nimport type { Context } from \"@oak/oak\";\n\nexport async function loggingMiddleware(\n  ctx: Context,\n  next: () => Promise<unknown>,\n): Promise<void> {\n  const start = performance.now();\n  const method = ctx.request.method;\n  const path = ctx.request.url.pathname;\n\n  await next();\n\n  const status = ctx.response.status;\n  const durationMs = Math.round(performance.now() - start);\n\n  console.log(\n    JSON.stringify({\n      event: \"http_request\",\n      method,\n      path,\n      status,\n      durationMs,\n    }),\n  );\n}\n```\n\nRegister it at the top of your middleware stack in `src/main.ts`\n\n, so it wraps\nevery request:\n\n``` js\nimport { loggingMiddleware } from \"./middleware/logging.ts\";\n\n// ...\n\napp.use(loggingMiddleware);\napp.use(corsMiddleware);\n// ...\n```\n\nAfter deploying, your Logs dashboard will show a clean stream of structured entries like this:\n\n```\n{\"event\":\"http_request\",\"method\":\"POST\",\"path\":\"/api/scores\",\"status\":200,\"durationMs\":43}\n{\"event\":\"http_request\",\"method\":\"GET\",\"path\":\"/api/leaderboard\",\"status\":200,\"durationMs\":18}\n```\n\nYou can filter by any field, for example, typing `status:500`\n\nto find all\nerrors, or `path:/api/scores`\n\nto see only score submissions.\n\nBusiness event logs\n\nBeyond request logging, you can emit structured events from inside your route handlers for application-level insight. When a score is saved, we log everything useful about that game:\n\n```\nconsole.log(\n  JSON.stringify({\n    event: \"score_submitted\",\n    playerName,\n    score,\n    globalRank: rank,\n    isNewRecord: rank === 1,\n    obstaclesAvoided,\n    gameDurationSeconds: gameDuration,\n    difficulty,\n  }),\n);\n```\n\nThis creates a searchable audit trail of every game that was played. Open the\nLogs dashboard, filter by `event:score_submitted`\n\n, and you have a live feed of\nplayer activity without any dedicated analytics infrastructure. We do the same\nfor customization saves:\n\n```\nconsole.log(\n  JSON.stringify({\n    event: \"customization_saved\",\n    playerName,\n    backgroundTheme,\n    dinoColor,\n    difficultyPreference,\n  }),\n);\n```\n\nErrors get the same treatment, using `console.error`\n\nmeans they’re easy to\ndistinguish in the dashboard:\n\n```\nconsole.error(\n  JSON.stringify({\n    event: \"score_submit_error\",\n    error: (error as Error).message,\n  }),\n);\n```\n\nTip:Logs emitted inside a custom span (see the next section) are automatically correlated with that trace in the dashboard. You can click a log line and jump straight to the trace it belongs to.\n\nTraces\n\nA trace is a record of a single operation as it flows through your system. It’s\nmade up of **spans**. These are named, timed units of work that nest inside each\nother to form a waterfall diagram.\n\nDeno Deploy automatically creates a root span for every incoming HTTP request\nand for every outbound `fetch`\n\ncall your code makes. What we’re adding here are\n**child spans** around the business logic inside each route handler, so you can\nsee exactly where time goes: is a slow `/api/leaderboard`\n\nresponse caused by the\ndatabase query, or something in the response serialisation?\n\nCreating a custom span\n\nThe pattern is the same everywhere: call `tracer.startActiveSpan()`\n\nwith a name,\ndo your work inside the callback, and call `span.end()`\n\nin a `finally`\n\nblock so\nthe span is always closed, even if an error is thrown.\n\n``` js\nimport { SpanStatusCode, tracer } from \"../telemetry.ts\";\n\nrouter.get(\"/api/leaderboard\", async (ctx: Context) => {\n  await tracer.startActiveSpan(\"leaderboard.fetch\", async (span) => {\n    try {\n      const limit = parseInt(ctx.request.url.searchParams.get(\"limit\") || \"10\");\n      span.setAttribute(\"leaderboard.limit\", limit);\n\n      // ... database query ...\n\n      span.setAttribute(\"leaderboard.rows_returned\", rows.length);\n      ctx.response.body = { success: true, leaderboard: rows };\n    } catch (error) {\n      span.recordException(error as Error);\n      span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });\n      throw error;\n    } finally {\n      span.end();\n    }\n  });\n});\n```\n\n**Span attributes** are key/value pairs attached to a span. They appear in the\ntrace detail view and can be used to filter traces. For example, filtering for\nspans where `leaderboard.rows_returned`\n\nis 0 would highlight times the database\nreturned an empty result.\n\n** span.recordException()** captures the full error object, including the stack\ntrace, and attaches it as a span event. This makes it much easier to debug\nproduction errors than reading plain error logs.\n\nSpans for score submission\n\nThe score submission route does the most work: it validates input, inserts the score, and checks the resulting global rank. All of that lives in a single span, with the most useful facts attached as attributes:\n\n``` js\nrouter.post(\"/api/scores\", async (ctx: Context) => {\n  await tracer.startActiveSpan(\"score.submit\", async (span) => {\n    try {\n      // ... parse and validate body ...\n\n      span.setAttributes({\n        \"game.player_name\": playerName,\n        \"game.score\": score,\n        \"game.difficulty\": difficulty,\n      });\n\n      // ... database insert + rank query ...\n\n      if (rank === 1) {\n        span.addEvent(\"new_global_record\", { score, playerName });\n      }\n\n      span.setAttribute(\"game.global_rank\", rank);\n      span.setAttribute(\"game.is_new_record\", rank === 1);\n    } finally {\n      span.end();\n    }\n  });\n});\n```\n\n`span.addEvent()`\n\nadds a timestamped marker inside the span, this is useful for\nsignificant moments that aren’t worth a whole new span. The `new_global_record`\n\nevent will appear as a point on the timeline in the trace viewer every time\nsomeone sets a new record.\n\nWe add the same pattern to the customization routes at `customization.load`\n\nand\n`customization.save`\n\n, each carrying attributes for player name, theme, and the\nsource of the settings (database, anonymous, or defaults):\n\n```\nspan.setAttributes({\n  \"game.player_name\": playerName,\n  \"customization.theme\": backgroundTheme,\n  \"customization.difficulty\": difficultyPreference,\n});\n```\n\nOnce deployed, open the **Traces** dashboard in Deno Deploy and click on any\n`POST /api/scores`\n\nrequest. You’ll see a waterfall: the outer HTTP span wrapping\nyour `score.submit`\n\nspan. Hover over your span to see the attributes and events\nyou attached, and click through to any correlated log lines.\n\nMetrics\n\nThe **Metrics** dashboard in Deno Deploy shows platform-level data that the\nruntime collects automatically, no code changes required. For this game, these\nare the most useful panels and what they tell you:\n\n**HTTP req/min by status code** is the clearest signal of overall health. A jump\nin 5xx responses after a deployment means something broke; a steady stream of\n4xx responses on `/api/scores`\n\nmight mean the client is sending malformed data.\n\n**HTTP mean latency** is your API’s average response time. If this climbs after\na deploy, check the Traces dashboard for whichever route has become slow. The\nlatency graph and the trace waterfall work together: the graph tells you\n*something* is slow, and the trace tells you *which part* is slow.\n\n**CPU time and memory usage** are useful for understanding the cost of traffic\nspikes. If the leaderboard gets shared and hundreds of players pile in, you’ll\nsee CPU and memory spike here. It’s also a good baseline check after adding new\ndatabase queries. A query without an index will show up as a CPU spike.\n\n**V8 garbage collection time** is the high GC time relative to total CPU time is\na sign of memory pressure, usually from allocating and discarding large objects\nin hot paths (like serialising a large leaderboard response on every request).\n\n**Total incoming / outgoing bandwidth** is helpful for spotting unexpectedly\nlarge responses. If outgoing bandwidth is high, the leaderboard response might\nbe returning more rows than expected, or a static asset might not be cached\ncorrectly.\n\nTogether, these panels give you a good picture of your app’s health without any extra configuration. When something looks wrong, the workflow is: spot the anomaly in Metrics, narrow it to a route using the status-code breakdown, then jump to Traces to find the slow or failing span.\n\nViewing it all locally with the tunnel\n\nDeno Deploy’s tunnel feature lets you run your server locally while routing telemetry through to the real Deno Deploy dashboards. This means you can verify your instrumentation is working before you deploy:\n\n```\ndeno task --tunnel dev\n```\n\nThe first time you run this, a browser will open to authenticate and ask which\napp to connect to. After that, your local traffic appears in the Deno Deploy\ndashboard under the `context:local`\n\nfilter.\n\nPlay the game locally, submit a score, then open the Traces dashboard and\nconfirm that the `score.submit`\n\nspan is there with the right attributes. Check\nthe Logs dashboard to see your `score_submitted`\n\nJSON line. Once you’re happy\nwith what you see, deploy for real:\n\n```\ndeno deploy --prod\n```\n\nWhat you can see now\n\n| Dashboard | What you’ll find |\n|---|---|\nLogs |\nOne JSON line per HTTP request (`event: \"http_request\"` ); structured events for `score_submitted` , `customization_saved` , and errors |\nTraces |\nCustom spans: `leaderboard.fetch` , `score.submit` , `scores.fetch_personal_bests` , `customization.load` , `customization.save` , each with attributes for player name, score, rank, theme, and source |\nMetrics |\nAutomatic platform data: HTTP req/min by status, mean latency, CPU time, memory usage, GC time, and bandwidth |\n\nWrapping up the series\n\nOver six posts, we’ve gone from a blank directory to a fully instrumented, globally distributed dinosaur runner game:\n\n- A basic Oak server serving static files\n- A canvas game loop with a playable dino character\n- Cactus obstacles and pixel-perfect collision detection\n- A PostgreSQL leaderboard with score submission via API\n- Player profiles: custom dino colors, themes, and difficulty settings\n- Full observability: structured logs, custom traces, and platform metrics\n\nThe instrumentation from this post gives you the visibility you need to operate the game in production, with the confidence to know when something breaks before your players do, and to understand how players are actually interacting with what you’ve built.\n\nThe full source is\n[available on GitHub](https://github.com/thisisjofrank/game-tutorial). Happy\nrunning! We’d love to see what you build next — share it with us on\n[Twitter](https://twitter.com/deno_land),\n[Bluesky](https://bsky.app/profile/deno.land), or\n[Discord](https://discord.gg/deno). 🦕", "url": "https://wpnews.pro/news/build-a-dinosaur-runner-game-with-deno-pt-6", "canonical_source": "https://deno.com/blog/build-a-game-with-deno-6", "published_at": "2026-02-23 15:00:00+00:00", "updated_at": "2026-05-22 12:19:06.770467+00:00", "lang": "en", "topics": ["developer-tools", "open-source", "cloud-computing", "data", "products"], "entities": ["Deno", "Deno Deploy", "PostgreSQL"], "alternates": {"html": "https://wpnews.pro/news/build-a-dinosaur-runner-game-with-deno-pt-6", "markdown": "https://wpnews.pro/news/build-a-dinosaur-runner-game-with-deno-pt-6.md", "text": "https://wpnews.pro/news/build-a-dinosaur-runner-game-with-deno-pt-6.txt", "jsonld": "https://wpnews.pro/news/build-a-dinosaur-runner-game-with-deno-pt-6.jsonld"}}