> ## Documentation Index
> Fetch the complete documentation index at: https://private-7c7dfe99-detect-table-modification.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# HackerNews Analyzer demo

> Instrument a Node.js app with an agent and send logs, traces, metrics, and session replay to ClickStack

export const AgentPrompt = ({prompt, title = "Agent-Assisted Setup", description, outline, outlineLabel = "What the agent will do", repositoryUrl, repositoryLabel = "ClickHouse/agent-skills"}) => {
  const [copied, setCopied] = useState(false);
  const handleCopy = async () => {
    const copyWithTextArea = () => {
      const textArea = document.createElement("textarea");
      textArea.value = prompt;
      textArea.style.position = "fixed";
      textArea.style.opacity = "0";
      document.body.appendChild(textArea);
      textArea.select();
      document.execCommand("copy");
      document.body.removeChild(textArea);
    };
    try {
      if (navigator?.clipboard?.writeText) {
        try {
          await navigator.clipboard.writeText(prompt);
        } catch {
          copyWithTextArea();
        }
      } else {
        copyWithTextArea();
      }
      setCopied(true);
      window.setTimeout(() => setCopied(false), 2000);
    } catch {}
  };
  return <div className="ch-agent-prompt-wrapper" data-mdast="ignore">
      <div className="ch-agent-prompt-main-row">
        <div className="ch-agent-prompt-left">
          <span className="ch-agent-prompt-title">{title}</span>
        </div>
        <div className="ch-agent-prompt-prompt-area" style={{
    overflow: "hidden"
  }}>
          <code className="ch-agent-prompt-prompt-text" style={{
    overflowX: "auto"
  }}>{prompt}</code>
        </div>
        <button type="button" className="ch-agent-prompt-copy-button" style={{
    boxSizing: "border-box",
    justifyContent: "center",
    minWidth: "8.25rem",
    whiteSpace: "nowrap"
  }} onClick={handleCopy} aria-label={copied ? "Copied" : "Copy prompt"}>
          {copied ? <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <polyline points="20 6 9 17 4 12" />
            </svg> : <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
              <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
            </svg>}
          <span style={{
    display: "grid",
    justifyItems: "center"
  }}>
            <span style={{
    gridArea: "1 / 1",
    visibility: copied ? "hidden" : "visible"
  }}>
              Copy Prompt
            </span>
            <span style={{
    gridArea: "1 / 1",
    visibility: copied ? "visible" : "hidden"
  }}>
              Copied
            </span>
          </span>
        </button>
      </div>
      {(description || repositoryUrl) && <div className="ch-agent-prompt-sub-row">
          {description && <span className="ch-agent-prompt-description">{description}</span>}
          {repositoryUrl && <a className="ch-agent-prompt-repository-link" href={repositoryUrl} target="_blank" rel="noopener noreferrer">
              {repositoryLabel}
            </a>}
        </div>}
      {outline?.length > 0 && <details className="ch-agent-prompt-outline">
          <summary className="ch-agent-prompt-outline-summary">
            <svg width="12" height="12" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg" className="ch-agent-prompt-outline-chevron" aria-hidden="true">
              <path d="M6.1584 3.13508C6.35985 2.94621 6.67627 2.95642 6.86514 3.15788L10.6151 7.15788C10.7954 7.3502 10.7954 7.64949 10.6151 7.84182L6.86514 11.8418C6.67627 12.0433 6.35985 12.0535 6.1584 11.8646C5.95694 11.6757 5.94673 11.3593 6.1356 11.1579L9.565 7.49985L6.1356 3.84182C5.94673 3.64036 5.95694 3.32394 6.1584 3.13508Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd" />
            </svg>
            <span>{outlineLabel}</span>
          </summary>
          <ol className="ch-agent-prompt-outline-list">
            {outline.map((item, index) => <li key={index}>{item}</li>)}
          </ol>
        </details>}
    </div>;
};

<Note>
  **TL;DR**

  Clone the [HackerNews Analyzer](https://github.com/ClickHouse/hn-news-analyzer), fill `.env` with your OTLP endpoint and token, then paste the agent prompt. The backend needs no OpenTelemetry imports; the agent wires `@hyperdx/node-opentelemetry` at process start.

  Time required: about 10 minutes
</Note>

The HackerNews Analyzer is a Node.js app that queries the HackerNews dataset hosted in the public ClickHouse demo. Every chart, table, and search box is a real ClickHouse query, so every interaction produces a trace whose main span is the HTTPS call from the backend out to ClickHouse.

This is a different job from the [session replay demo](/clickstack/example-datasets/session-replay), which instruments a browser-only app against local Docker ClickStack. Here you get backend auto-instrumentation, ClickHouse query spans, and session replay from the same app.

<h2 id="prerequisites">
  Prerequisites
</h2>

* Node 18+ and npm
* A ClickStack OTLP/HTTP endpoint and ingestion token:
  * **ClickHouse Cloud:** open the service, then **ClickStack** → **Configure your OpenTelemetry exporter** → **Env vars**. Protocol is `http/protobuf`. Headers are `authorization=<ingestion token>` with no `Bearer` prefix.
  * **Local collector:** use `http://localhost:4318`. If the collector is unsecured, leave `authorization=` empty.

<h2 id="clone-the-repository">
  Clone the repository
</h2>

Clone [HackerNews Analyzer](https://github.com/ClickHouse/hn-news-analyzer), install dependencies, and copy the env template:

```bash theme={null}
git clone https://github.com/ClickHouse/hn-news-analyzer.git
cd hn-news-analyzer
npm install
cp .env.example .env
```

You'll fill `.env` in the next steps, then instrument from this directory.

<h2 id="instrument-the-application">
  Instrument the application
</h2>

<Steps>
  <Step title="Run the application" id="run-the-application">
    From the cloned `hn-news-analyzer` directory, start the app. The ClickHouse data source defaults to the public read-only demo cluster, so it runs without any further configuration:

    ```bash theme={null}
    ./run.sh
    ```

    Open [http://localhost:5001](http://localhost:5001). You will see a year selector, summary statistics, an activity chart, top users and domains tables, and a search box. Click around: switch years, drill into stories.

    <Frame>
      <img src="https://mintcdn.com/private-7c7dfe99-detect-table-modification/BitaxT7H2ijD0nYp/images/clickstack/getting-started/hackernews_main.webp?fit=max&auto=format&n=BitaxT7H2ijD0nYp&q=85&s=4c4ea078f1b70b7f5b4d12989ba8c57e" alt="The HackerNews Analyzer application running locally" width="2872" height="1474" data-path="images/clickstack/getting-started/hackernews_main.webp" />
    </Frame>

    At this point the application is running but uninstrumented. ClickStack shows no data: it is waiting for telemetry.
  </Step>

  <Step title="Configure environment" id="configure-environment">
    The SDKs read standard OpenTelemetry exporter variables. They are not hardcoded in source. Open `.env` and set:

    ```bash theme={null}
    OTEL_SERVICE_NAME=hn-analyzer-api
    OTEL_EXPORTER_OTLP_ENDPOINT=<your-otlp-http-endpoint>
    OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
    OTEL_EXPORTER_OTLP_HEADERS=authorization=<your-ingestion-token>
    OTEL_TRACES_EXPORTER=otlp
    OTEL_METRICS_EXPORTER=otlp
    OTEL_LOGS_EXPORTER=otlp
    ```

    `OTEL_EXPORTER_OTLP_ENDPOINT` is the OTLP/HTTP endpoint (port `4318`). `OTEL_EXPORTER_OTLP_HEADERS` is the authorization header, in the form `authorization=<token>` with no `Bearer` prefix.

    If the collector does not enforce auth, leave the token empty (`OTEL_EXPORTER_OTLP_HEADERS=authorization=`). The variable must still be present; the SDK skips initialization if it is unset or fully empty.

    The browser SDK reuses these same values. `vite.config.ts` bakes the endpoint and token into the public bundle at build time, so use a throwaway ingestion token, not a production one.
  </Step>

  <Step title="Instrument the application" id="instrument">
    Pick one path. All three end at the same instrumented app.

    <Tabs>
      <Tab title="Agent instrumentation" id="instrument-with-an-agent">
        With the repo cloned and `.env` filled in, paste this prompt into a coding agent **in that directory** to instrument the application.

        <AgentPrompt
          prompt="Use curl to download, read and follow: github.com/ClickHouse/hn-news-analyzer/blob/main/agent.md"
          description="After you clone hn-news-analyzer and fill .env, run this prompt from that directory. Works with Claude Code, Cursor, Codex, and other coding agents."
          outline={[
"Confirm you are in the cloned hn-news-analyzer directory and that .env already has OTEL_EXPORTER_OTLP_* values. Stop if either is missing.",
"Install @hyperdx/node-opentelemetry and switch run.sh to opentelemetry-instrument.",
"Install @hyperdx/browser and enable HyperDX.init plus HyperDX.addAction.",
"Start the app, confirm OTLP health checks pass, and tell you to click around at http://localhost:5001.",
]}
        />
      </Tab>

      <Tab title="Manual instrumentation" id="instrument-manually">
        Instrumentation has three parts: install the SDKs, switch the launch command, and enable the browser SDK. None of it changes the application's business logic.

        <h3 id="install-node-sdk">
          Install the Node SDK
        </h3>

        ```bash theme={null}
        npm install @hyperdx/node-opentelemetry
        ```

        <h3 id="enable-run-sh-wrapper">
          Enable the wrapper in run.sh
        </h3>

        The bottom of `run.sh` has two `exec` lines. Comment the plain `node` line and uncomment the instrumented one:

        ```diff theme={null}
         # BEFORE: plain node, no instrumentation:
        -exec node scripts/entrypoint.js
        +# exec node scripts/entrypoint.js

         # AFTER: same source, wrapped by opentelemetry-instrument:
        -# exec npx opentelemetry-instrument scripts/entrypoint.js
        +exec npx opentelemetry-instrument scripts/entrypoint.js
        ```

        Keep launching through `scripts/entrypoint.js`. That shim calls `require('console')` so console capture wraps `console.log`. Pointing `opentelemetry-instrument` at `dist/server/index.js` directly ships traces but silently drops logs.

        <h3 id="enable-browser-sdk">
          Enable the browser SDK
        </h3>

        ```bash theme={null}
        npm install @hyperdx/browser
        ```

        In `src/web/telemetry.ts`, uncomment the import, the `HyperDX.init({...})` block, and `HyperDX.addAction` in `recordAction()`:

        ```diff theme={null}
        -// import HyperDX from '@hyperdx/browser';
        +import HyperDX from '@hyperdx/browser';

         export function initTelemetry(): void {
        -  // HyperDX.init({
        -  //   url: __OTLP_ENDPOINT__,
        -  //   apiKey: __OTLP_AUTH_TOKEN__,
        -  //   service: 'hn-analyzer-web',
        -  //   tracePropagationTargets: [/localhost:5001/i, /\/api\//i],
        -  //   consoleCapture: true,
        -  //   advancedNetworkCapture: true,
        -  // });
        +  HyperDX.init({
        +    url: __OTLP_ENDPOINT__,
        +    apiKey: __OTLP_AUTH_TOKEN__,
        +    service: 'hn-analyzer-web',
        +    tracePropagationTargets: [/localhost:5001/i, /\/api\//i],
        +    consoleCapture: true,
        +    advancedNetworkCapture: true,
        +  });
         }
        ```

        `__OTLP_ENDPOINT__` and `__OTLP_AUTH_TOKEN__` are compile-time constants injected by `vite.config.ts` from the same `OTEL_EXPORTER_OTLP_*` values the backend uses.

        <Warning>
          The ingestion token is baked into the public browser bundle and is readable by anyone inspecting the network tab. Use a throwaway token.
        </Warning>
      </Tab>

      <Tab title="Use a pre-instrumented branch" id="use-the-instrumented-branch">
        To skip instrumentation and start with an already instrumented application, check out the [`instrumented` branch](https://github.com/ClickHouse/hn-news-analyzer/tree/instrumented).

        ```bash theme={null}
        git checkout instrumented
        npm install
        ```

        Don't run `./reset.sh` on this branch unless you want to strip the SDKs.
      </Tab>
    </Tabs>
  </Step>

  <Step title="Generate traffic and view telemetry" id="generate-traffic-and-view-telemetry">
    Restart the application so the new launch command and freshly built browser bundle take effect:

    ```bash theme={null}
    # Ctrl-C the previous run, then:
    ./run.sh
    ```

    Confirm the startup banner prints three "Health check passed" lines for `/v1/traces`, `/v1/metrics`, and `/v1/logs`. Reload the browser tab so Vite serves the updated bundle, then switch years and click into stories to generate traffic.

    Open the ClickStack UI:

    1. Go to **Search** and filter to the last 5 minutes. Logs for `hn-analyzer-api` stream in.

    <Frame>
      <img src="https://mintcdn.com/private-7c7dfe99-detect-table-modification/BitaxT7H2ijD0nYp/images/clickstack/getting-started/instrument_app_clickstack_logs.webp?fit=max&auto=format&n=BitaxT7H2ijD0nYp&q=85&s=dc408878170b45221c6d1a384d5d62c6" alt="ClickStack search showing hn-analyzer-api logs from the last five minutes" width="3018" height="1578" data-path="images/clickstack/getting-started/instrument_app_clickstack_logs.webp" />
    </Frame>

    2. Click into a request and walk up the trace. You will see the Express handler span, a child HTTP span pointing at `sql-clickhouse.clickhouse.com` with real network duration, and correlated `console.log` records on the same trace.

    <Frame>
      <img src="https://mintcdn.com/private-7c7dfe99-detect-table-modification/BitaxT7H2ijD0nYp/images/clickstack/getting-started/instrument_app_clickstack_traces.webp?fit=max&auto=format&n=BitaxT7H2ijD0nYp&q=85&s=f85bd33406f2602790517acebac49579" alt="ClickStack trace with an Express handler span and a child HTTP span to ClickHouse" width="2398" height="1590" data-path="images/clickstack/getting-started/instrument_app_clickstack_traces.webp" />
    </Frame>

    3. Open **Session Replay** to play back a scrubbable video of a browser session, synced to the trace timeline.

    <Frame>
      <img src="https://mintcdn.com/private-7c7dfe99-detect-table-modification/BitaxT7H2ijD0nYp/images/clickstack/getting-started/instrument_app_clickstack_sessions.webp?fit=max&auto=format&n=BitaxT7H2ijD0nYp&q=85&s=f936f62ebbeed95561a82276b547b958" alt="ClickStack session replay synced to the trace timeline" width="2408" height="1580" data-path="images/clickstack/getting-started/instrument_app_clickstack_sessions.webp" />
    </Frame>

    Logs, metrics, traces, and session replays land in the same UI, share the same query language, and are correlated automatically.
  </Step>
</Steps>

<h2 id="learn-more">
  Learn more
</h2>

* [HackerNews Analyzer](https://github.com/ClickHouse/hn-news-analyzer): the demo repository this guide instruments.
* [Session Replay](/clickstack/features/session-replay): feature overview, SDK options, and privacy controls.
* [Session Replay Demo](/clickstack/example-datasets/session-replay): a self-contained demo with a local ClickStack instance.
* [ClickStack Getting Started](/clickstack/getting-started/index): deploy ClickStack and ingest your first data.
* [All Sample Datasets](/clickstack/example-datasets/index): other example datasets and guides.
