Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Service map

‹ 16 of 22 ›

Service map

People need to see service dependencies and locate problems among them.

Updated September 10, 2026

Problem

People investigating a service failure need to understand its dependencies, including ones they do not already know.

Solution

Show services as nodes and observed calls as connections. State the time window and what data the map is based on so the visible graph is not mistaken for a complete architecture diagram.

Keep node positions stable where possible. Group larger systems into useful areas and summarize each service’s condition, with details available on selection.

Show direction clearly and link to supporting traces or metrics. A failing connection identifies an area to investigate, not necessarily the original cause.

Use when

Service dependencies are too numerous to remember and people need to follow relationships during investigation.

Don’t use when

There are eight services and everyone knows them. Also don’t use it as a monitoring surface: a topology view is a navigation and blast-radius tool, and a page that watches it continuously would be better off watching the four metrics that matter.

Trade-offs

Unmeasured dependencies remain invisible. Dense graphs need grouping that can hide details, while layouts that move on refresh are hard to learn. Shared failures do not by themselves establish which service caused the problem.

Checklist

  • Is the map derived from traces, or from something a human maintains?
  • What is the time window, and does an edge disappear when calls stop?
  • Does a node carry one derived state, or several competing metrics?
  • Is the layout stable between refreshes and between people?
  • Can the viewer see call volume and error rate on an edge, not just its existence?
  • What happens at a hundred services, and what grouping is available?
  • Can you get from a node to that service’s traces, logs and dashboards?
  • Are uninstrumented dependencies flagged, or silently absent?
  • Is direction of the call visible, and distinguishable from direction of blame?
  • Does a colourblind reader get the same health reading as everyone else?

Compare

Datadog builds the map automatically from APM traces, consolidates each service’s health into one border colour, and ages entries out after thirty days of silence, so the graph maintains itself in both directions. Jaeger ships a plainer dependency view derived from the same span parent-child relationships, which is enough to answer “who calls this” without pretending to be a monitoring surface. Grafana approaches it through Tempo’s service graph, assembled from span metrics, so the topology lives beside the dashboards rather than in a separate product. Netdata works at the host and container layer instead, mapping what is running where rather than what calls what, which answers a different question people often bring to the same screen.

Trace waterfall shows one request behind the map. Host map focuses on infrastructure. Drill-down opens a service or connection.

Semantic status color defines the displayed health states. Overview then detail places the map within a wider investigation path.

Service map anatomy Nodes for services and edges for the calls between them, both derived from traces rather than from a diagram anyone drew. One node carries a derived health state on its border, one edge is unhealthy, and one dependency nobody documented is drawn because something actually called it. Observed, not drawn gateway checkout payments pricing tax postgres 1 2 3 1 THE UNHEALTHY EDGE An aggregate of calls from one service to another, so the edge can be sick while both nodes look fine on their own pages. 2 ONE DERIVED STATE Consolidated from anomalies, monitors and incidents. A node showing five metrics is a hairball; one showing a state is scannable in a second. 3 THE ONE NOBODY DREW A wiki diagram describes what somebody intended eighteen months ago. This describes what made a request four minutes ago. The map also needs a definition of "gone", or it accumulates every service that ever existed. Aging a node out after a month without traces is one. Drawing the graph is easy. Laying it out is not: real service graphs are dense, cyclic and unbalanced, and a map that reshuffles on refresh cannot be learned.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A topology where the edges carry the interesting state. Node health is easy and mostly known; it is the dependency that is timing out which nobody can find in a list.

Tokens
--foreground--card--muted-foreground--border--status-warn--status-critical
gateway → checkout: 1180/min, 0.2% errorsgateway → pricing: 640/min, 0.1% errorscheckout → payments: 310/min, 17.0% errorscheckout → tax: 305/min, 0.0% errorspricing → postgres: 900/min, 0.0% errorspayments → postgres: 40/min, 0.0% errorsgatewaycheckout! paymentspricingtaxpostgres

ServiceMap.tsxNodes and edges from trace data, one derived state per node, an edge sick on its own error rate, new edges dashed, silent nodes aged off, positions stored so the layout holds.

import type { Status } from "../semantic-status-color/status";

/**
 * Observed, not drawn. A node is a service that produced traces, an edge is
 * calls between two of them, and both carry the two dates that give the map
 * a definition of "new" and of "gone". Positions are stored with the node,
 * because a map that reshuffles on refresh cannot be learned.
 */
export type Service = {
  id: string;
  x: number;
  y: number;
  /** One derived state per node. Five metrics on a node is a hairball. */
  status: Status;
  lastSeen: Date;
};

export type Call = {
  from: string;
  to: string;
  /** Per minute, over the window. Width follows it. */
  calls: number;
  errorRate: number;
  firstSeen: Date;
};

const DAY = 86_400_000;
/** An edge this young is drawn dashed: the dependency nobody had drawn. */
const NEW_FOR = 7 * DAY;
/** A node silent this long is gone, so the map does not accumulate history. */
const GONE_AFTER = 30 * DAY;
const UNHEALTHY = 0.05;

const NODE_H = 30;
const STROKE: Record<Status, string> = {
  nominal: "stroke-border", warn: "stroke-status-warn", critical: "stroke-status-critical", unknown: "stroke-muted-foreground",
};
const TEXT: Record<Status, string> = {
  nominal: "fill-foreground", warn: "fill-status-warn", critical: "fill-status-critical", unknown: "fill-muted-foreground",
};
/** A glyph beside the label, so the state survives greyscale. */
const GLYPH: Record<Status, string> = { nominal: "", warn: "▲ ", critical: "! ", unknown: "? " };

const width = (s: Service) => Math.max(56, s.id.length * 7 + 16);

/** Where a line from a to b leaves b's box, so the arrowhead sits on the edge. */
const clip = (a: Service, b: Service) => {
  const dx = b.x - a.x, dy = b.y - a.y;
  const t = Math.min((width(b) / 2) / Math.abs(dx || 1e-9), (NODE_H / 2) / Math.abs(dy || 1e-9));
  return { x: b.x - dx * t, y: b.y - dy * t };
};

export function ServiceMap({ services, calls, now, onSelect, width: w = 400, height = 250 }: {
  services: Service[];
  calls: Call[];
  /** The clock the window is measured from. */
  now: Date;
  onSelect: (id: string) => void;
  width?: number;
  height?: number;
}) {
  const live = services.filter((s) => now.getTime() - s.lastSeen.getTime() < GONE_AFTER);
  const byId = Object.fromEntries(live.map((s) => [s.id, s]));
  const edges = calls.filter((c) => byId[c.from] && byId[c.to]);
  const peak = Math.max(...edges.map((c) => c.calls));

  return (
    <div className="rounded-lg border bg-card p-4">
      <svg viewBox={`0 0 ${w} ${height}`} className="w-full" role="img" aria-label={`${live.length} services, ${edges.length} dependencies`}>
        <defs>
          <marker id="svc-arrow" viewBox="0 0 6 6" refX="5" refY="3" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
            <path d="M0 0 L6 3 L0 6 z" className="fill-muted-foreground" />
          </marker>
        </defs>
        {edges.map((c) => {
          const a = byId[c.from], b = byId[c.to];
          const end = clip(a, b);
          const sick = c.errorRate >= UNHEALTHY;
          const fresh = now.getTime() - c.firstSeen.getTime() < NEW_FOR;
          return (
            <line key={`${c.from}-${c.to}`} x1={a.x} y1={a.y} x2={end.x} y2={end.y}
              className={sick ? "stroke-status-critical" : fresh ? "stroke-muted-foreground" : "stroke-border"}
              strokeWidth={1.5 + 2 * (c.calls / peak)} strokeDasharray={fresh ? "5 4" : undefined} markerEnd="url(#svc-arrow)">
              <title>{`${c.from} → ${c.to}: ${c.calls}/min, ${(c.errorRate * 100).toFixed(1)}% errors`}</title>
            </line>
          );
        })}
        {live.map((s) => (
          <g key={s.id} role="button" tabIndex={0} onClick={() => onSelect(s.id)} onKeyDown={(e) => e.key === "Enter" && onSelect(s.id)} className="cursor-pointer">
            <rect x={s.x - width(s) / 2} y={s.y - NODE_H / 2} width={width(s)} height={NODE_H} rx="3"
              className={`fill-card ${STROKE[s.status]}`} strokeWidth={s.status === "nominal" ? 1.5 : 2} />
            <text x={s.x} y={s.y + 4} textAnchor="middle" className={`${TEXT[s.status]} text-[9px]`}>{GLYPH[s.status]}{s.id}</text>
          </g>
        ))}
      </svg>
    </div>
  );
}

demo.tsxHow it is called: six services, one failing edge, one dependency three days old, one node a month gone.

import { useState } from "react";
import { ServiceMap, type Call, type Service } from "./ServiceMap";

/**
 * Six services as traces saw them in the last few minutes. checkout to
 * payments is failing one call in six, so that edge is the sick one while
 * both nodes' own pages look fine; payments to postgres showed up three days
 * ago and nobody had drawn it. The clock is fixed so the ages are stable.
 */
const NOW = new Date("2026-09-15T09:00:00Z");
const ago = (days: number) => new Date(NOW.getTime() - days * 86_400_000);

const SERVICES: Service[] = [
  { id: "gateway",  x: 48,  y: 59,  status: "nominal",  lastSeen: NOW },
  { id: "checkout", x: 197, y: 47,  status: "nominal",  lastSeen: NOW },
  { id: "payments", x: 308, y: 47,  status: "critical", lastSeen: NOW },
  { id: "pricing",  x: 190, y: 127, status: "nominal",  lastSeen: NOW },
  { id: "tax",      x: 278, y: 127, status: "nominal",  lastSeen: NOW },
  { id: "postgres", x: 196, y: 205, status: "nominal",  lastSeen: NOW },
  // Retired in July. Still in the data, aged off the map.
  { id: "legacy-cart", x: 80, y: 205, status: "unknown", lastSeen: ago(62) },
];

const CALLS: Call[] = [
  { from: "gateway",  to: "checkout", calls: 1180, errorRate: 0.002, firstSeen: ago(400) },
  { from: "gateway",  to: "pricing",  calls: 640,  errorRate: 0.001, firstSeen: ago(400) },
  { from: "checkout", to: "payments", calls: 310,  errorRate: 0.17,  firstSeen: ago(400) },
  { from: "checkout", to: "tax",      calls: 305,  errorRate: 0.0,   firstSeen: ago(300) },
  { from: "pricing",  to: "postgres", calls: 900,  errorRate: 0.0,   firstSeen: ago(400) },
  { from: "payments", to: "postgres", calls: 40,   errorRate: 0.0,   firstSeen: ago(3) },
];

export default function Demo() {
  const [picked, setPicked] = useState<string | null>(null);
  return (
    <div>
      <ServiceMap services={SERVICES} calls={CALLS} now={NOW} onSelect={setPicked} />
      {picked && <p className="mt-2 text-xs text-muted-foreground">open traces, logs and dashboards for {picked}</p>}
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Node graph panel September 10, 2026 Grafana Play (signed out; no version string exposed) sparse · dark · desktop-web
Several service nodes extend beyond the panel’s right edge, so the full graph is not visible at once. Each node contains two values and a colored ring, with a legend explaining the measures. This adds detail but makes the map harder to scan for a single overall state. The zoom control helps reach the clipped nodes, though a clearer initial fit would make the relationships easier to inspect.