dangerousmetrics.com

Anvil (The Geek Stuff)

Nginx Logs

A ground-up walkthrough of nginx access logs, from raw events to actionable signal.

Scope

This system is concerned with understanding what actually happened at the edge of a web service. It operates on the premise that every completed request leaves behind a factual record, and that those records are the most reliable source of truth available once a request has passed.

The focus here is on individual events, not summaries. Each request is treated as an observation, not a statistic. The system is designed to preserve those observations long enough to be questioned later, even when the questions were not known at the time the data was collected.

This scope explicitly values completeness over convenience. Data is not sampled, pre-aggregated, or reshaped to fit predefined dashboards. Any loss of detail is considered a tradeoff that must be consciously chosen, not an accidental side effect of optimization.

Just as important are the boundaries. This system does not attempt to explain application intent, user motivation, or business meaning. It does not infer success beyond what can be observed at the request boundary. It does not replace metrics, tracing, or application-level logging.

In practice, this means the system answers questions like what was requested, when it was requested, how it was handled, and what response was produced. Questions about why a user behaved a certain way or whether a request achieved a business goal are intentionally out of scope.

The goal is clarity, not completeness of narrative. By drawing a hard line around what is observed and refusing to speculate beyond it, the system remains dependable even when other sources of truth disagree.


The raw input

At its core, this system begins with a single artifact: one line of text written by nginx after a request has completed. Each line is a factual record of an interaction between a client and the server.

Below are three real examples representing common outcomes. Each line corresponds to a single HTTP request and response cycle.

baremetalbridge.com 54.85.239.163 - - [08/Feb/2026:23:22:22 +0000] "GET /the-silent-rebellion-fast-food-wars-and-the-power-of-the-dollar/ HTTP/1.1" 404 659 "-" "Grammarly/1.0 (http://www.grammarly.com)"
baremetalbridge.com 114.119.156.9 - - [08/Feb/2026:23:22:34 +0000] "GET /monday-boot-sequence HTTP/1.1" 301 5 "-" "Mozilla/5.0 (Linux; Android 7.0;) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; PetalBot;+https://webmaster.petalsearch.com/site/petalbot)"
baremetalbridge.com 114.119.156.9 - - [08/Feb/2026:23:22:35 +0000] "GET /monday-boot-sequence/ HTTP/1.1" 200 4984 "-" "Mozilla/5.0 (Linux; Android 7.0;) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; PetalBot;+https://webmaster.petalsearch.com/site/petalbot)"

The first request resulted in a 404, indicating that the server successfully handled the request but could not find a matching resource.

The second request returned a 301, signaling a permanent redirect. The server is instructing the client to request a different location.

The third request completed with a 200, meaning the requested content was located and returned successfully.

These records describe outcomes, not intent. They tell us what was requested, how it was handled, and when it occurred. Everything else must be inferred carefully.


Log format and fields

Nginx access logs do not have an inherent structure. They only become meaningful when a format is declared and consistently enforced. The log format defines both the order of fields and the implicit contract for how each request will be recorded.

Below is the access log format used as the canonical example throughout this document. Every log line shown earlier was produced using this definition.

log_format dm_access '$host $remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent"';

Each field in this format exists for a specific reason. Together, they describe what was requested, when it happened, who made the request, and how the server responded. Importantly, this format records outcomes without embedding interpretation.

  • $host identifies the virtual host that handled the request. This is critical when multiple sites share the same edge.
  • $remote_addr records the client IP address as seen by nginx at the time of the request.
  • $remote_user represents authenticated user identity, when applicable. A dash indicates no authenticated user.
  • $time_local captures when the request completed, using the server’s local time format. This is later normalized but preserved here as emitted.
  • $request contains the HTTP method, requested path, and protocol version as a single atomic string.
  • $status records the HTTP response status code returned to the client.
  • $body_bytes_sent indicates how many bytes were sent in the response body, excluding headers.
  • $http_referer captures the referring page, when provided by the client.
  • $http_user_agent records the client’s declared software identity.

None of these fields attempt to explain intent. They simply record what nginx observed. Meaning is derived later, not embedded here.

This format is applied uniformly across all hosts using a shared access log directive. This ensures that every log line, regardless of origin, adheres to the same structural contract.

access_log /var/log/nginx/dangerousmetrics.com/access.log dm_access;

Enforcing a single log format across all hosts is not a convenience. It is a prerequisite for reliable analysis. Without a consistent structure, parsing becomes guesswork and comparisons between systems become fragile.

By fixing the format at the edge, all downstream systems can assume the same field order, the same semantics, and the same guarantees. This is what allows logs from different machines to be treated as a single, coherent data surface.


Parsing and normalization

Parsing is the act of assigning structure to text. Normalization is the act of assigning consistency to that structure. Together, they form the boundary between raw observation and usable data.

At this stage, the goal is not insight. The goal is fidelity. Parsing must extract what was recorded without adding meaning, and normalization must make values comparable without changing what they represent.

The parsing process begins with position and agreement. Each field in the log line is interpreted according to its declared order and expected format. If a field cannot be confidently identified, that failure is itself meaningful and must not be silently corrected.

Normalization follows parsing and exists to remove superficial variation. Timestamps are aligned to a common time base. Numeric values are converted from strings to numbers. Empty or missing fields are made explicit rather than implied.

There are strict limits on what normalization is allowed to do. It may standardize representation, but it must not collapse distinct values, infer intent, or rewrite outcomes. A request that returned a redirect remains a redirect. A missing field remains missing.

Parsing and normalization are intentionally conservative operations. They are designed to preserve the full expressive range of the original log line while making it possible to ask precise questions later.

Any transformation that introduces interpretation, correlation, or summarization belongs to a later stage. At this boundary, the only acceptable loss is superficial inconsistency. Loss of meaning is not permitted.


Time, clocks, and ordering

Time is the axis that makes logs useful, and it is also the axis most likely to betray you. Every request recorded in an access log is bound to a moment, and nearly every meaningful question you will ask later depends on that moment being represented correctly.

Consider the timestamp emitted in the example log entries:

[08/Feb/2026:23:22:22 +0000]

This value represents the time at which the request completed, as recorded by the nginx worker handling the connection. It includes a date, a time, and an explicit offset from UTC. At the moment it is written, it is still just text. Its meaning exists, but it is implicit.

For analysis systems like OpenSearch, that implicit meaning must be made explicit. Timestamps are not treated as strings because strings do not understand time. They sort lexically, not temporally. They cannot be ranged accurately. They cannot be aggregated into intervals without reinterpretation.

When a timestamp is stored as a true temporal type, the system gains the ability to order events correctly, even across hosts. It can answer questions about sequences, gaps, bursts, and correlations. Without this, time-based analysis quietly degrades into approximation.

Normalizing timestamps to UTC is a necessary step in this process. Different machines may run in different locales. Clock offsets may vary. Daylight saving changes introduce ambiguity. By converting all observed times to a single, unambiguous reference, events from different sources can be placed onto the same timeline.

Even with normalization, time remains imperfect. Clocks drift. Virtual machines pause. Network delays reorder arrival. Logs reflect when an event was observed, not when it was intended. This is not a flaw of logging, it is a property of distributed systems.

Because of this, time must be handled carefully and conservatively. It should be preserved, normalized, and indexed accurately, but never overinterpreted. Ordering is a tool for reasoning, not a guarantee of causality.

Treating timestamps as first-class temporal data is what allows later systems to ask precise questions without lying to themselves. Treating them as strings turns time into decoration.


Enrichment and context

Enrichment is the process of adding context to an event so it can be understood more easily later. In many logging systems, this step becomes a catch-all where missing information is inferred, joined, or reconstructed after the fact.

In this particular case, enrichment plays a much smaller role than it often does. That is intentional. The nginx access logs used here are already emitted with the full set of contextual fields required for meaningful analysis.

Host identity, request details, response status, byte counts, referrer, and user agent are all captured at the edge, at the moment the request completes. This means the most important enrichment has already occurred before the log line ever exists.

Because of this, there is no need to perform heavy downstream enrichment for these logs. No additional lookups are required to understand what happened. No external systems need to be consulted to add meaning that was missing at write time.

This section exists primarily as a reference point, not as an active transformation stage. It establishes where enrichment would occur if it were needed, and more importantly, where it should not be forced when it is unnecessary.

If you need a refresher on where and how this context was established, refer back to the Log format and fields section. That is where the enrichment boundary is intentionally drawn for these logs.

The guiding principle is simple. Context is most trustworthy when it is recorded at the point of observation. The further enrichment is pushed downstream, the more likely it is to introduce assumptions.

Later chapters will revisit enrichment in cases where raw signals do not carry sufficient meaning on their own. For nginx access logs, that work has already been done at the edge, and repeating it would add complexity without improving truth.


Storage strategy

Once log events have been parsed and normalized, they must be placed somewhere that preserves their structure, ordering, and queryability over time. The storage layer is not just a destination. It is part of the contract that determines what questions can be asked later and how reliably they can be answered.

The strategy used here is built around treating access logs as immutable events. Each request is written once, indexed once, and never rewritten to fit a new interpretation. Corrections and derived insights are handled downstream rather than by mutating the original record.

This approach places several non-negotiable requirements on storage. Events must be stored in a system that understands time as a first class concept, supports structured fields with explicit types, and allows efficient querying across large volumes without requiring pre-aggregation.

The system used to satisfy these requirements is OpenSearch. It is chosen not because it is fashionable or convenient, but because it aligns well with the needs of event-oriented data. It provides indexing over time-based documents, flexible schemas that can be controlled explicitly, and powerful query capabilities without collapsing raw events into summaries prematurely.

Feeding data into this storage layer requires a reliable ingestion mechanism that can accept raw log lines, apply parsing and normalization consistently, and deliver structured events without loss. That role is handled by Vector, which acts as the boundary between raw text and indexed documents.

Importantly, this section describes intent, not implementation. The choice of Vector and OpenSearch reflects the constraints outlined here, but the reasoning matters more than the tools themselves. A different stack could be substituted if it honored the same rules.

The following two sections describe how these decisions are applied in practice. The first covers how log data is ingested and prepared for storage. The second covers how that data is indexed, mapped, and stored in OpenSearch to preserve its usefulness over time.


OpenSearch indexing and storage

OpenSearch is used here as the system of record for parsed nginx access events. Its role is not to interpret or summarize data, but to store immutable events in a way that preserves structure, ordering, and queryability at scale.

The key design decision is that the shape of the data is defined explicitly before any events are indexed. This is done through an index template that declares field names, types, and constraints up front. Documents are expected to conform to this contract.

Below is the index template used for these access logs. It defines how each field is stored and indexed, and just as importantly, what is not allowed.

{
  "settings": {
    "index": {
      "number_of_shards": 1,
      "number_of_replicas": 0
    }
  },
  "mappings": {
    "dynamic": false,
    "properties": {
      "timestamp": {
        "type": "date",
        "format": "dd/MMM/yyyy:HH:mm:ss Z"
      },
      "host": {
        "type": "keyword"
      },
      "client_ip": {
        "type": "ip"
      },
      "user": {
        "type": "keyword"
      },
      "request": {
        "type": "text"
      },
      "method": {
        "type": "keyword"
      },
      "path": {
        "type": "keyword",
        "ignore_above": 2048
      },
      "protocol": {
        "type": "keyword"
      },
      "status": {
        "type": "integer"
      },
      "bytes": {
        "type": "long"
      },
      "referrer": {
        "type": "keyword",
        "ignore_above": 2048
      },
      "user_agent": {
        "type": "text"
      },
      "env": {
        "type": "keyword"
      },
      "log_type": {
        "type": "keyword"
      },
      "pipeline": {
        "type": "keyword"
      },
      "parse_ok": {
        "type": "boolean"
      },
      "source": {
        "type": "keyword"
      },
      "message": {
        "type": "text",
        "index": false
      }
    }
  }
}

Setting dynamic to false is a deliberate choice. It prevents OpenSearch from inventing fields on the fly based on incoming documents. Any field that is not declared here is rejected rather than silently indexed with an inferred type.

This protects the index from schema drift. Without this constraint, a single malformed event can introduce new fields, conflicting types, or unexpected mappings that permanently alter query behavior.

Field types are chosen to reflect how the data will be used. Keywords are used where exact matching or aggregation matters. Numeric types are used where ordering and range queries are required. The timestamp is stored as a true date so time-based queries and sorting behave correctly.

The template also establishes limits. Long strings are capped. Raw messages are stored but not indexed. This keeps storage predictable and avoids paying indexing costs for data that will never be queried directly.

Importantly, ingestion tools do not need to understand this mapping. Vector does not need to know field types or index settings. It only needs to emit documents with the correct field names and values.

Index templates are applied by OpenSearch at index creation time. When a matching index is created, the mapping is enforced automatically. Any incoming document is validated against it.

This separation of responsibility is intentional. OpenSearch owns the schema. Ingestion tools remain simple and focused on delivery. The result is a system where structure is centralized, enforced, and resistant to accidental change.

The next section describes how Vector prepares and emits events so they conform to this mapping without embedding OpenSearch-specific logic into the ingestion pipeline.


Vector ingestion and parsing

Vector is the ingestion boundary between raw log files and structured events. Its role is to accept log input from multiple sources, apply parsing and normalization deterministically, and emit documents that conform to the storage contract defined earlier.

Until recently, Logstash was the log parser of choice in this role. While powerful, its overhead and breadth of features were not justified for this use case. The pipeline here does not require the majority of Logstash filters, codecs, or dynamic behavior.

Filebeat was also considered, but it is tightly coupled to the Elastic ecosystem and assumes conventions that do not align cleanly with this deployment. Vector fills the gap by remaining lightweight, explicit, and agnostic about downstream storage.

The configuration below defines the complete ingestion pipeline for nginx access logs. It shows how log lines enter the system, how they are parsed and normalized, and how they are delivered to OpenSearch without embedding OpenSearch-specific logic into the parsing stage.

sources:
  nginx_dev_file:
    type: file
    include:
      - /var/log/nginx/dev.dangerousmetrics.com/access.log
    read_from: beginning

  nginx_www_file:
    type: file
    include:
      - /var/log/nginx/dangerousmetrics.com/access.log
    read_from: beginning

  nginx_syslog:
    type: syslog
    address: 0.0.0.0:1514
    mode: udp

transforms:
  parse_nginx:
    type: remap
    inputs:
      - nginx_dev_file
      - nginx_www_file
      - nginx_syslog
    source: |
      parsed, err = parse_regex(
        .message,
        r'^(?P<host>\S+) (?P<client_ip>\S+) - (?P<user>\S+) \[(?P<timestamp>[^\]]+)\] "(?P<request>[^"]*)" (?P<status>\d+|-) (?P<bytes>\d+|-) "(?P<referrer>[^"]*)" "(?P<user_agent>[^"]*)"'
      )

      if err != null {
        abort
      }

      . |= parsed

  normalize:
    type: remap
    inputs:
      - parse_nginx
    source: |
      if exists(.source) {
        if contains!(.source, "/var/log/nginx/dev") {
          .env = "dev"
        } else if contains!(.source, "/var/log/nginx/") {
          .env = "www"
        }
      }

      if !exists(.env) {
        .env = "remote"
      }

      .status = to_int(.status) ?? null
      .bytes  = to_int(.bytes)  ?? null

      .parse_ok = false

      if exists(.request) {
        req, err = parse_regex(
          .request,
          r'^(?P<method>\S+)\s+(?P<path>\S+)(?:\s+(?P<protocol>\S+))?$'
        )

        if err == null {
          .method   = req.method
          .path     = req.path
          .protocol = req.protocol
          .parse_ok = true
        }
      }

      .log_type = "nginx_access"
      .pipeline = "vector_dm_access"

sinks:
  opensearch_out:
    type: elasticsearch
    inputs:
      - normalize
    endpoints:
      - http://localhost:9200
    suppress_type_name: true
    mode: bulk
    bulk:
      index: nginx-access-%Y.%m

The pipeline begins with three sources. Two read nginx access logs directly from disk, representing different virtual hosts. The third listens for syslog input over UDP, allowing remote or forwarded logs to enter the same processing path.

All three sources feed into a single parsing transform. This transform applies a regular expression that mirrors the nginx log format described earlier. If a line does not match the expected structure, it is aborted rather than partially parsed.

This behavior is intentional. Failed parsing is treated as a signal, not something to be silently repaired. Only events that fully conform to the expected format are promoted to structured data.

The normalization stage applies controlled cleanup and decomposition. Environment tags are derived from the source path, allowing events to be grouped by origin without external lookup. Status codes and byte counts are converted to numeric types to match the OpenSearch mapping.

The request line is further decomposed into method, path, and protocol. A boolean flag records whether this secondary parsing succeeded, preserving visibility into partial failures without discarding the event.

Finally, static metadata fields are attached to identify the log type and the pipeline that produced the event. These fields provide trace ability and future-proofing without affecting core semantics.

The sink delivers events to OpenSearch using bulk indexing. The index name is time-based, allowing retention and lifecycle policies to be applied without rewriting data. Vector does not define the schema. OpenSearch enforces it using the index template described earlier.

This separation of concerns is the central design choice. Vector is responsible for correctness and consistency of events. OpenSearch is responsible for validation, indexing, and query behavior. Neither needs to understand the internals of the other.


Querying and exploration

How raw events become questions you can actually answer.

Purpose

At this point in the pipeline, ingestion and parsing are complete. Raw log lines have been accepted, normalized, and stored under a schema that enforces consistency. No additional interpretation occurs here.

This section focuses on retrieval. It examines how stored events are queried, bounded, and returned from OpenSearch in response to explicit questions. The goal is not to summarize or explain the data, but to show how evidence is accessed before any higher-level processing is applied.

What querying means here

Querying is the act of retrieving stored events that match a specific set of constraints. A query does not explain the data, interpret it, or assign meaning. It returns records that satisfy the conditions that were asked for, nothing more.

In this system, queries are intentionally narrow and explicit. They operate over a known index pattern, within a bounded result set, and return fields exactly as they were stored. No aggregation, summarization, or inference is applied unless it is explicitly requested.

This distinction is important. Querying exposes evidence. Any conclusions drawn from that evidence happen outside the query itself.

The simplest possible question

Every system supports complex questions, but those questions are built on top of simpler ones. The most basic question this system can answer is also the most revealing.

What are the most recent events that have been recorded?

This question avoids interpretation entirely. It does not ask why something happened or whether it matters. It asks only for evidence, ordered by time, and nothing else.

Example query

{
  "size": 150,
  "sort": [
    {
      "timestamp": {
        "order": "desc"
      }
    }
  ],
  "_source": [
    "timestamp",
    "status",
    "message",
    "client_ip"
  ],
  "query": {
    "match_all": {}
  }
}
  • The query targets a time-based index pattern containing nginx access log events.
  • Results are sorted by the timestamp field in descending order, returning the most recent events first.
  • The result set is explicitly bounded to 150 documents to keep retrieval predictable and inexpensive.
  • Only a small subset of fields is returned. The original log message is included alongside a few extracted fields, without modification.
  • No filtering or aggregation is applied. The query returns the most recent stored events exactly as they exist.

Running the query directly

The same query can be executed directly against OpenSearch using standard HTTP tooling. No application logic is required. The request below is shown exactly as it would be issued from a shell on the host.

curl -u admin:admin \
  -X POST "http://localhost:9200/nginx-access-*/_search" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 150,
    "sort": [
      {
        "timestamp": {
          "order": "desc"
        }
      }
    ],
    "_source": [
      "timestamp",
      "status",
      "message",
      "client_ip"
    ],
    "query": {
      "match_all": {}
    }
  }'

This request returns the same documents, in the same order, as the example query shown above. The application endpoint used elsewhere on this site performs this request on behalf of the browser and applies only minimal shaping to the response.

Execute the query

Query results

No query has been executed yet.

What you are seeing

The output above is the raw JSON response returned by OpenSearch for the example query. No fields have been removed, renamed, or interpreted. This is the response body exactly as it was received.

At the top level, the response includes execution metadata. The took field reports how long the query took to execute in milliseconds. The timed_out flag indicates whether the query exceeded its execution window. The _shards object reports how many shards participated in the query and whether all of them responded successfully.

The hits.total value represents the total number of documents that match the query criteria across the index, not the number of documents returned. In this case, thousands of events match, but only a small subset is returned due to the explicit size limit.

The hits.hits array contains the actual documents returned by the query. Each entry corresponds to a single stored event. These documents are ordered by the sort criteria defined in the query, most recent first.

For each document, metadata fields such as _index, _id, and sort are included alongside the _source object. The _source field contains the stored event itself, including the original log message and the extracted fields defined by the ingestion pipeline.

No aggregation has been applied. Each entry represents an individual event, not a summary or a computed metric. This is the same underlying data used by the Logs view elsewhere on this site. The difference is presentation, not content.

How this site uses the data

When the example query button is pressed, the browser does not communicate with OpenSearch directly. Instead, it issues a request to a local application endpoint. That endpoint performs the query on behalf of the client and returns the response unchanged.

This indirection is intentional. Credentials, network access, and query limits are enforced server-side, while the client remains a simple consumer of results. The browser never receives direct access to the datastore.

import { NextResponse } from "next/server";

export const runtime = "nodejs";

const OPENSEARCH = process.env.OPENSEARCH_URL;
const INDEX = "nginx-access-*";
const LIMIT = 5;

export async function GET() {
  const res = await fetch(`${OPENSEARCH}/${INDEX}/_search`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    cache: "no-store",
    body: JSON.stringify({
      size: LIMIT,
      sort: [
        {
          timestamp: {
            order: "desc",
          },
        },
      ],
      _source: [
        "timestamp",
        "status",
        "message",
        "client_ip",
      ],
      query: {
        match_all: {},
      },
    }),
  });

  if (!res.ok) {
    return NextResponse.json(
      { error: "OpenSearch query failed" },
      { status: 500 }
    );
  }

  const json = await res.json();

  return NextResponse.json(json);
}

This endpoint defines a fixed index pattern and a hard result limit, executes the query, and returns the raw response body. No aggregation, enrichment, or interpretation is applied. The response is passed through exactly as OpenSearch produced it.

The OpenSearch connection is provided through an environment variable rather than a hard-coded hostname. This allows the same code to run unchanged across development and production environments while keeping connection details out of the application logic.

On the client side, the JSON returned by this endpoint is rendered verbatim using a standard serializer. The application does not attempt to explain, summarize, or decorate the data at this stage.

This pattern is used throughout the site. Client-side components interact with narrow, purpose-built endpoints. Those endpoints query backing systems and return evidence in a form that can be inspected before any higher-level processing is applied.

But the Logs section does not look like that

The raw query shown above returns documents exactly as OpenSearch stores them. That response is intentionally verbose, metadata-heavy, and structured for machines rather than people. The Logs section elsewhere on this site presents the same underlying events, but through a deliberately shaped interface.

This difference is not accidental. The Logs view is built on top of the raw query mechanism, but it applies a minimal transformation step to make the data readable at a glance. Individual events are still preserved. What changes is presentation, not evidence.

Lets dig into the next section and see how the Logs section actually does it.


Visualizing the logs

How raw events are rendered into a readable, continuous stream.

The Front End

The Logs section of this site consumes the shaped output produced by the query endpoint shown earlier. It does not issue raw OpenSearch queries and does not perform any interpretation beyond presentation. Its role is to render a continuous, readable stream of events.

The frontend expects a small, explicit data structure for each log entry. Each object represents a single event and includes the original log line, a timestamp, the numeric status code, and a coarse status classification.

type LogRow = {
  id: number;
  event_time: string;
  status: number;
  status_class: "2xx" | "3xx" | "4xx" | "5xx";
  line: string;
};

Status classification is used only for visual emphasis. No filtering or grouping occurs on the client. Each status class maps to a color that allows error conditions to stand out without suppressing successful requests.

function statusColor(cls: LogRow["status_class"]) {
  switch (cls) {
    case "2xx":
      return "text-emerald-400";
    case "3xx":
      return "text-sky-400";
    case "4xx":
      return "text-amber-400";
    case "5xx":
      return "text-red-400 font-semibold";
    default:
      return "text-neutral-300";
  }
}

The page polls the backend at a fixed interval. Each request retrieves a bounded set of recent events. The client does not attempt to merge or deduplicate results across polls. Instead, it replaces the buffer entirely on each refresh.

useEffect(() => {
  let alive = true;

  async function load() {
    const res = await fetch("/api/logs/recent");
    const data = await res.json();
    if (alive) {
      setLogs(data.logs ?? []);
      setLoading(false);
    }
  }

  load();
  const timer = setInterval(load, 4000);

  return () => {
    alive = false;
    clearInterval(timer);
  };
}, []);

When new data arrives, the buffer automatically scrolls to the bottom. This produces a terminal-style tail behavior that mirrors how operators commonly inspect live logs. The page disables body scrolling to ensure that scroll focus remains inside the log buffer.

Rendering is intentionally simple. Each log line is printed as plain text with minimal styling. Long lines are wrapped rather than truncated. No syntax highlighting or parsing is performed at this stage.

The visual design borrows from terminal conventions to reinforce the idea that this view is observational. It is meant for scanning and situational awareness, not analysis. Every line corresponds to an actual stored event.

The important constraint is consistency. The Logs page renders the same events returned by the query layer. It does not invent data, suppress records, or derive metrics. It is a presentation layer over evidence.


Aggregation and rollups

When aggregation helps, when it lies, and when it destroys evidence.

Raw logs answer a simple question: what happened. Each row represents a real event that occurred at a specific moment in time.

Aggregations answer a different question: how often something happened, grouped by a rule. Instead of returning events, the query collapses many documents into buckets and produces counts or calculations derived from them.

This is where charts come from. Time series graphs, bar charts, and counters are all visual representations of aggregated data, not raw evidence.

That distinction matters. Once data is aggregated, individual events are no longer visible. You cannot point at a specific request, IP, or log line. You gain shape and trend, but you lose detail.

Used correctly, aggregations provide situational awareness. Used carelessly, they can hide failure modes, smooth over bursts, and erase the very anomalies operators need to see.

A simple volume aggregation

The route below demonstrates a minimal aggregation used to drive a request volume chart. It does not return log lines. It returns counts per time bucket.

{
  "size": 0,
  "query": {
    "bool": {
      "filter": [
        {
          "range": {
            "timestamp": {
              "gte": "now-24h",
              "lte": "now"
            }
          }
        },
        {
          "term": {
            "parse_ok": true
          }
        }
      ]
    }
  },
  "aggs": {
    "volume": {
      "date_histogram": {
        "field": "timestamp",
        "fixed_interval": "1m",
        "min_doc_count": 0
      }
    }
  }
}

The query explicitly sets size: 0, ensuring that no documents are returned. Only aggregation results are requested.

A date histogram groups events into one minute buckets based on their timestamp. Each bucket contains a document count representing the number of matching requests during that interval.

Running the aggregation directly

Aggregations are ordinary OpenSearch queries. They can be executed directly from the command line without any application code in between. This makes it easy to verify behavior and inspect raw responses before introducing visualization or shaping layers.

curl -u admin:admin \
  -X POST "http://localhost:9200/nginx-access-*/_search" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "query": {
      "bool": {
        "filter": [
          {
            "range": {
              "timestamp": {
                "gte": "now-24h",
                "lte": "now"
              }
            }
          },
          {
            "term": {
              "parse_ok": true
            }
          }
        ]
      }
    },
    "aggs": {
      "volume": {
        "date_histogram": {
          "field": "timestamp",
          "fixed_interval": "1m",
          "min_doc_count": 0
        }
      }
    }
  }'

The request explicitly sets size: 0, which tells OpenSearch to return no documents. Only aggregation buckets are included in the response.

Each bucket represents a one minute interval and contains a document count for matching events. Buckets with no traffic are still returned, which preserves gaps and makes drops in activity visible.

What comes back from this request is already complete. Any chart or panel built on top of it is simply a visual encoding of these buckets. No additional insight is added downstream.

Execute the aggregation

Aggregation results

No aggregation has been executed yet.

How the chart is rendered

Nothing special happens on the client to produce the chart. The work was already done by the route. By the time the data reaches React, it is in a shape that the charting library can consume directly.

The component maintains a small piece of state to hold the derived time series. Each element represents a single aggregation bucket with an authoritative timestamp and a count.

const [series, setSeries] = useState<{ ts: number; count: number }[] | null>(null);

async function runAggregation() {
  setLoading(true);
  setError(null);
  setResult(null);
  setSeries(null);

  try {
    const res = await fetch("/api/book/nginx-logs/logcountagg");
    if (!res.ok) {
      throw new Error(`request failed: ${res.status}`);
    }

    const json = await res.json();

    setResult(JSON.stringify(json.raw, null, 2));
    setSeries(json.series);
  } catch (err: any) {
    setError(err.message ?? "unknown error");
  } finally {
    setLoading(false);
  }
}

The fetch handler does two things. It preserves the raw aggregation response for inspection, and it extracts the preformatted time series. No additional transformation is performed on the client.

Once the series state is populated, React re-renders the component. That re-render is all the charting library needs.

<ResponsiveContainer width="100%" height="100%">
  <LineChart data={series}>
    <XAxis
      dataKey="ts"
      tickFormatter={(v) =>
        new Date(v).toLocaleTimeString([], {
          hour: "2-digit",
          minute: "2-digit"
        })
      }
      ticks={[
        series[0]?.ts,
        series[Math.floor(series.length * 0.2)]?.ts,
        series[Math.floor(series.length * 0.4)]?.ts,
        series[Math.floor(series.length * 0.6)]?.ts,
        series[Math.floor(series.length * 0.8)]?.ts,
        series[series.length - 1]?.ts
      ]}
      tick={{ fill: "#a3a3a3", fontSize: 11 }}
      axisLine={false}
      tickLine={false}
    />

    <Line
      type="monotone"
      dataKey="count"
      stroke="#a3a3a3"
      strokeWidth={2}
      dot={false}
    />
  </LineChart>
</ResponsiveContainer>

The chart receives the series array and maps each object directly to a point. The X axis uses the epoch timestamp as its domain. The line uses the count field as its value. There is no implicit aggregation or smoothing at this stage.

The responsive container does not calculate data. It only measures available space and resizes the chart accordingly. All semantics come from the data and the configuration above.

This is the important takeaway. Once an aggregation has been reduced to a simple time series, rendering it as a chart becomes a mechanical process. The chart reflects the buckets it is given. Any loss of detail occurred earlier.


Having Fun With Data

An example of expressive rendering built on the same processed events.

Temporal Comets (last 24h)

Animated request bursts over the last 24 hours. Larger comets indicate higher volume.

If you explore the Site section of this page, you will see this same visualization used in a live context. The component shown above uses the same base aggregation you just worked through, but the route prepares the data differently to make it suitable for ThreeJS.

The query itself should look familiar. It is the same aggregation pattern, but it is intentionally hard coded to a six hour window and a one minute resolution. This keeps playback dense enough to be visually interesting while remaining inexpensive to compute.

The difference comes after the aggregation. Instead of returning buckets directly, the route performs a small amount of math to normalize counts and reshape the data into a form that is easy to animate. This math is not analytical. It exists purely to support rendering.

import { NextResponse } from "next/server";

export const runtime = "nodejs";

const OPENSEARCH = "http://localhost:9200";
const INDEX = "nginx-access-*";

type StatusClass = "2xx" | "3xx" | "4xx" | "5xx";

function classify(status: number): StatusClass {
  if (status >= 500) return "5xx";
  if (status >= 400) return "4xx";
  if (status >= 300) return "3xx";
  return "2xx";
}

export async function GET() {
  try {
    const res = await fetch(`${OPENSEARCH}/${INDEX}/_search`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      cache: "no-store",
      body: JSON.stringify({
        size: 0,
        query: {
          bool: {
            filter: [
              {
                range: {
                  timestamp: {
                    gte: "now-6h",
                    lte: "now"
                  }
                }
              },
              {
                term: { parse_ok: true }
              }
            ]
          }
        },
        aggs: {
          by_minute: {
            date_histogram: {
              field: "timestamp",
              fixed_interval: "1m",
              min_doc_count: 0
            },
            aggs: {
              by_status: {
                terms: {
                  field: "status",
                  size: 10
                }
              }
            }
          }
        }
      })
    });

    if (!res.ok) {
      const text = await res.text();
      console.error("opensearch error:", text);
      return NextResponse.json({ range: { start: 0, end: 0 }, events: [] });
    }

    const json = await res.json();

    const events: {
      t: number;
      class: StatusClass;
      count: number;
      intensity: number;
    }[] = [];

    let globalMax = 0;

    // Pass 1: compute raw counts and global max
    for (const bucket of json.aggregations.by_minute.buckets) {
      const ts = bucket.key;

      for (const s of bucket.by_status.buckets) {
        const count = s.doc_count;
        if (count <= 0) continue;

        if (count > globalMax) globalMax = count;

        events.push({
          t: ts,
          class: classify(s.key),
          count,
          intensity: 0
        });
      }
    }

    // Pass 2: normalize intensity
    for (const e of events) {
      e.intensity = globalMax > 0 ? e.count / globalMax : 0;
    }

    events.sort((a, b) => a.t - b.t);

    return NextResponse.json({
      range: {
        start: events[0]?.t ?? 0,
        end: events.at(-1)?.t ?? 0
      },
      events
    });
  } catch (err) {
    console.error("infobar route failed:", err);
    return NextResponse.json({ range: { start: 0, end: 0 }, events: [] });
  }
}

The first pass preserves reality. Counts are calculated exactly as returned by OpenSearch. The second pass introduces a normalized intensity value that allows the renderer to scale motion and size without reinterpreting the underlying data.

This route is intentionally opinionated. It is not reusable as a general purpose query, and it is not intended for inspection. Its only job is to take already trustworthy data and make it easy to express visually.

This is where the pipeline stops. Everything above this point was about correctness and fidelity. Everything here is about expression.

Once the data reaches the browser, the rules change slightly. The pipeline has already done its job. What remains is no longer about correctness or fidelity, but about motion, timing, and presentation.

Because this visualization is rendered using ThreeJS, some additional math is required on the client. This math does not alter the underlying data. It exists solely to control how events appear, move, and fade over time.

At a high level, the browser is doing four things. It compresses a fixed time range into a short playback window, maps normalized intensity values to visual size, assigns motion to each event, and removes events once they leave the visible field. None of this math is analytical. It is all cinematic.

"use client";

import { Canvas, useFrame } from "@react-three/fiber";
import { Text } from "@react-three/drei";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";

/* ---------------- Types ---------------- */

type StatusClass = "2xx" | "3xx" | "4xx" | "5xx";

type CometEvent = {
  t: number;
  class: StatusClass;
  count: number;
  intensity: number;
};

type ApiResponse = {
  range: {
    start: number;
    end: number;
  };
  events: CometEvent[];
};

type ActiveComet = {
  id: number;
  event: CometEvent;
  bornAt: number;
  size: number;
  vx: number;
  vy: number;
  ref: React.RefObject<THREE.Group | null>;
};

/* ---------------- Constants ---------------- */

const COLORS: Record<StatusClass, string> = {
  "2xx": "#22c55e",
  "3xx": "#eab308",
  "4xx": "#3b82f6",
  "5xx": "#ef4444",
};

const PLAYBACK_SECONDS = 60;
const BASE_SPEED = 6;
const VIEW_WIDTH = 16;
const LIFETIME_MS = (VIEW_WIDTH / BASE_SPEED) * 1000;

const FULL_SIZE_THRESHOLD = 0.3;
const COLLISION_DAMPING = 0.98;

/* ---------------- Utils ---------------- */

function hashToRange(
  value: unknown,
  min: number,
  max: number
) {
  const str = String(value ?? "__unknown__");

  let h = 0;
  for (let i = 0; i < str.length; i++) {
    h = (h << 5) - h + str.charCodeAt(i);
    h |= 0;
  }

  const norm = (h >>> 0) / 0xffffffff;
  return min + norm * (max - min);
}

function formatTime(ts: number) {
  return new Date(ts).toLocaleTimeString([], {
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
  });
}

/* ---------------- Comet ---------------- */

function Comet({ comet }: { comet: ActiveComet }) {
  const textRef = useRef<any>(null);

  useFrame((_state: any, delta: number) => {
    const group = comet.ref.current;
    if (!group) return;

    group.position.x += comet.vx * delta;
    group.position.y += comet.vy * delta;

    if (comet.event.class !== "2xx") {
      comet.vy += (Math.random() - 0.5) * 0.15;
    }

    const age = performance.now() - comet.bornAt;
    const alpha = Math.max(0, 1 - age / LIFETIME_MS);

    if (textRef.current?.material) {
      textRef.current.material.opacity = alpha;
    }
  });

  return (
    <group
      ref={comet.ref}
      position={[
        -8,
        hashToRange(comet.event.t, -2.5, 2.5),
        0,
      ]}
    >
      <Text
        ref={textRef}
        fontSize={comet.size * 3.2}
        color={COLORS[comet.event.class]}
        anchorX="left"
        anchorY="middle"
        outlineWidth={0.015}
        outlineColor="#000000"
        material-transparent
        material-opacity={1}
      >
        {formatTime(comet.event.t)} ({comet.event.count})
      </Text>
    </group>
  );
}

Events are introduced into the scene based on their timestamp relative to the playback window. Size is derived from normalized intensity. Horizontal motion represents the passage of time. Vertical motion and collision effects exist purely to prevent overlap and introduce visual texture.

Importantly, none of this logic feeds back into the data pipeline. The animation cannot change counts, reorder events, or invent new ones. It is a one way transformation from already processed data into motion.

This is the final boundary. Everything before this point was about making data trustworthy. Everything after is optional, expressive, and safely disposable.


Where We Leave Things

A complete pipeline, and a deliberate stopping point.

This walkthrough began with a single line of text written by nginx after a request completed. From there, it followed that line through parsing, normalization, storage, querying, aggregation, and rendering. At no point did it attempt to explain intent, assign importance, or decide meaning.

That restraint is the point. Systems like this exist to preserve evidence, not to narrate it. Once an event has passed, the log is often the most reliable artifact left behind. If that artifact is reshaped too early or interpreted too aggressively, the opportunity to ask new questions later is permanently lost.

Every decision shown here was made with that in mind. Log formats were fixed at the edge. Parsing and normalization were conservative. Storage enforced schema rather than inventing it. Queries returned evidence before summaries. Aggregations were explicit about what they discarded. Visualizations were built on top of known reductions rather than hidden ones.

The reason for walking through this in such detail is simple. Too many systems hide these steps behind tools, dashboards, and defaults. The result is that people learn how to consume outputs without ever learning how those outputs came to exist. When something looks wrong, there is nowhere to stand and nothing solid to question.

Knowing how to build a pipeline like this is not about nginx, OpenSearch, or any specific technology. It is about understanding where truth is preserved, where it is reduced, and where it is intentionally discarded. Once you know where those boundaries are, you can choose to cross them consciously instead of accidentally.

The final visualizations were included for a reason. When data is handled carefully upstream, it becomes safe to explore, experiment, and even play downstream. Expression does not threaten correctness as long as it remains one way.

This is where the system stops. Everything beyond this point is analysis, judgment, and decision making. Those steps matter, but they are human work. They cannot be automated without cost, and they should not be hidden behind machinery.

If there is a single takeaway, it is this: treat raw events with respect. Preserve them longer than you think you need to. Make every transformation visible. And always know which parts of your system are showing you evidence, and which parts are telling you a story.