Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,14 @@ jobs:
target: "/home/devops/deploy"
- name: Execute commands
uses: appleboy/ssh-action@v1.0.3
env:
GA_MP_API_SECRET: ${{ secrets.GA_MP_API_SECRET }}
with:
host: ${{ secrets.IP }}
username: devops
key: ${{ secrets.SSH_KEY_PRIVATE }}
port: ${{ secrets.PORT }}
envs: GA_MP_API_SECRET
script_stop: true
script: |
cd ~/deploy
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ npm run serve:ssr:interviewer

Opens at `http://localhost:4000`

### Runtime environment variables (SSR server)

| Variable | Default | Purpose |
|---|---|---|
| `PORT` | `4000` | SSR server listening port |
| `GA_MP_API_SECRET` | _(unset)_ | GA4 Measurement Protocol API secret. When set, the SSR server sends a `visitor_hit` event for crawler / AI-agent requests so bot traffic shows in Google Analytics. Unset = no-op (dev/local). See [`docs/ga-ssr-visitor-type.md`](docs/ga-ssr-visitor-type.md). |
| `GA_MEASUREMENT_ID` | `G-6GS9Y7GJD3` | Optional override for the GA4 measurement ID used by the Measurement Protocol send. |

In production the secret is stored as the `GA_MP_API_SECRET` GitHub Actions secret and forwarded into the deploy SSH session (`appleboy/ssh-action` `envs:` in `.github/workflows/deploy.yml`), where `docker-compose` substitutes it into `${GA_MP_API_SECRET}` in `docker-compose.frontend.yml`. It never enters the repo or image.

## Docker

```sh
Expand Down
1 change: 1 addition & 0 deletions docker-compose.frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ services:
- 8080:4000
environment:
- NODE_ENV=production
- GA_MP_API_SECRET=${GA_MP_API_SECRET}
networks:
- interviewer-network
133 changes: 133 additions & 0 deletions docs/ga-ssr-visitor-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Сбор visitor_type в Google Analytics (SSR + Measurement Protocol)

Инструкция по ручной настройке на стороне Google Analytics (GA4) для фичи, которая
различает посетителей сайта: человек / поисковый краулер / AI-агент и шлёт это в
аналитику.

## Зачем это нужно

Классификация посетителя происходит на **сервере** (Express SSR), а обычный GA4
работает в **браузере** и срабатывает только когда исполняется JavaScript. Боты и
AI-агенты JS не исполняют, поэтому в браузерный GA они не попадают. Чтобы видеть их
статистику, сервер отправляет события напрямую через **GA4 Measurement Protocol**.

Для этого нужно вручную сделать две вещи в интерфейсе GA4:

1. получить секретный ключ `GA_MP_API_SECRET`;
2. зарегистрировать специальный параметр `visitor_type`.

Названия пунктов меню даны на русском, в скобках — на английском.

---

## Задача 1. Получить значение `GA_MP_API_SECRET`

1. Зайти на **https://analytics.google.com** под своим аккаунтом.
2. Вверху слева убедиться, что выбран нужный **ресурс (property)** — тот, к которому
относится веб-поток с Measurement ID `G-6GS9Y7GJD3` (сайт techinterview.space).
3. Внизу левого меню нажать на шестерёнку **«Администратор» (Admin)**.
4. В столбце **«Ресурс» (Property)** найти раздел **«Сбор и изменение данных»
(Data collection and modification)** → нажать **«Потоки данных» (Data streams)**.
5. В списке нажать на свой **веб-поток (Web)** — там будет домен techinterview.space.
6. Прокрутить панель с деталями потока **вниз до конца** → пункт **«Секреты API
Measurement Protocol» (Measurement Protocol API secrets)**. Нажать на него.
7. Нажать **«Создать» (Create)**.
8. Ввести понятное название (nickname), например `ssr-server`. Нажать **«Создать»**.
9. Появится строка **«Секретное значение» (Secret value)** — длинный набор символов.
**Скопировать его.** Это и есть `GA_MP_API_SECRET`.

> Это секрет. Не публиковать, не коммитить в гит. Куда вставить на сервере — см. раздел
> «Куда положить секрет на сервере» ниже.

На той же панели вверху виден **Measurement ID** = `G-6GS9Y7GJD3` — это вторая часть,
она уже известна, отдельно ничего делать не нужно.

---

## Задача 2. Настроить сбор `visitor_type` (специальный параметр)

Без этого шага GA будет получать значение `visitor_type`, но **не покажет его в
отчётах**.

1. В **«Администратор» (Admin)**, столбец **«Ресурс» (Property)**.
2. Раздел **«Отображение данных» (Data display)** → **«Специальные определения»
(Custom definitions)**.
3. Вкладка **«Специальные параметры» (Custom dimensions)**.
4. Кнопка **«Создать специальный параметр» (Create custom dimension)** справа вверху.
5. Заполнить поля:

| Поле | Значение |
|---|---|
| **Название параметра** (Dimension name) | `visitor_type` |
| **Область действия** (Scope) | **«Событие» (Event)** |
| **Описание** (Description) | `human / search-crawler / ai-agent` |
| **Параметр события** (Event parameter) | `visitor_type` (точно так же, маленькими буквами) |

6. Нажать **«Сохранить» (Save)**.

> Поле **«Параметр события»** должно быть строго `visitor_type` — так же, как
> отправляет код. Опечатка или другой регистр — данные не свяжутся.

---

## Важные нюансы

- **Данные не собираются задним числом.** Параметр начнёт собираться только **после**
создания, поэтому создать его нужно до выкатки кода на прод.
- **В обычных отчётах появится через 24–48 часов** — это нормально.
- **Проверить сразу** можно в **«Отчёты» → «В реальном времени» (Realtime)** или в
**«Администратор» → DebugView** — там событие `visitor_hit` видно через пару минут.
- Само событие `visitor_hit` отдельно регистрировать не нужно — GA подхватывает новые
события автоматически.

---

## Куда положить секрет на сервере

Сервер читает ключ из переменной окружения `GA_MP_API_SECRET` во время работы
(runtime), а не из Angular `environment.ts` (те значения вшиваются в бандл на этапе
сборки и утекли бы в публичный код).

Фронтенд на проде запускается через `docker-compose.frontend.yml` на Droplet
(DigitalOcean), а деплой идёт через GitHub Actions (`.github/workflows/deploy.yml`).
Секрет прокидывается через пайплайн:

1. В GitHub репозитории добавить секрет: **Settings → Secrets and variables →
Actions → New repository secret** → имя `GA_MP_API_SECRET`, значение — из Задачи 1.

2. В `docker-compose.frontend.yml` уже есть ссылка на переменную (без самого секрета):

```yaml
environment:
- NODE_ENV=production
- GA_MP_API_SECRET=${GA_MP_API_SECRET}
```

3. В шаге деплоя (`appleboy/ssh-action`) секрет пробрасывается в SSH-сессию через
`envs`, и `docker-compose` подставляет его в `${GA_MP_API_SECRET}`:

```yaml
- name: Execute commands
uses: appleboy/ssh-action@v1.0.3
env:
GA_MP_API_SECRET: ${{ secrets.GA_MP_API_SECRET }}
with:
# ...
envs: GA_MP_API_SECRET
```

Так секрет хранится только в GitHub Secrets (рядом с остальными — `DO_TOKEN`,
`SSH_KEY_PRIVATE`) и не попадает в гит, образ или файл на сервере. Ротация: поменять
секрет в GitHub и передеплоить.

**Локально:** если переменная не задана — код ничего не отправляет (no-op). Чтобы
проверить отправку локально: `GA_MP_API_SECRET=xxx npm run serve:ssr:interviewer`.

---

## Как смотреть статистику

Когда данные накопятся: **«Исследования» (Explore)** → отчёт «Свободная форма»
(Free-form) → параметр (dimension) **`visitor_type`**, показатель (metric)
**«Количество событий» (Event count)**, при желании фильтр по событию `visitor_hit`.
Получится сравнение: люди / поисковые краулеры / AI-агенты.
27 changes: 26 additions & 1 deletion docs/geo.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,28 @@ The response also sets:

Implementation: `src/server.ts` (the final `app.use(...)` SSR handler). The turndown instance strips `<script>`, `<style>`, `<noscript>`, and `<iframe>` before conversion.

### 12. Visitor Classification & Bot Analytics

An early Express middleware classifies every request as `human`, `search-crawler`, or `ai-agent` from its `User-Agent`. GA4 is client-side and never fires for non-JS bots, so the SSR server is the only place crawler and AI-agent traffic is countable.

**Files:** `src/server/visitor-type.util.ts` (pure classifier + spec), `src/server.ts` (middleware), `src/server/ga-measurement-protocol.util.ts` (GA4 send), `src/app/services/visitor-analytics.service.ts` (human client-side event).

The `ai-agent` bucket covers both model-training crawlers (`GPTBot`, `ClaudeBot`, `Google-Extended`, `CCBot`, `Bytespider`, …) and live on-demand fetchers (`ChatGPT-User`, `OAI-SearchBot`, `Claude-User`, `Perplexity-User`) — the latter mean a real person is reading the page through an AI assistant. AI patterns are matched before search-crawler patterns so `-Extended`/`-User` variants win over their plain counterparts (e.g. `Google-Extended` → `ai-agent`, `Googlebot` → `search-crawler`).

Every request is classified and gets:
- `res.locals.visitorType` — available to downstream handlers if we ever want to serve different content per visitor type.
- `X-Visitor-Type` response header — informational; behind a CDN it can be cached, so it is a debug aid (e.g. for `curl` against origin), not a reliable per-visitor signal.

Measurement (the `console.log` line and the GA4 send) happens **only in the Angular SSR handler, for actual page renders**, not for static assets or `/.well-known` / `/sitemap.xml` requests — otherwise a JS-rendering crawler pulling sub-resources would inflate the counts. The `console.log` JSON line (`event: "visitor"`) is emitted only for non-human visitors and is greppable from PM2/stdout.

**GA4 reporting.** Both sides emit a custom event `visitor_hit` with a `visitor_type` param so all three buckets land in one comparable dimension:
- Humans → `VisitorAnalyticsService` fires it client-side per navigation (only when GA is configured, i.e. production).
- Bots → the server sends it via the GA4 Measurement Protocol (`POST /mp/collect`), fire-and-forget, gated on `GA_MP_API_SECRET`, with the bot User-Agent omitted so GA4's bot filter does not drop it.

Manual GA4 setup (Measurement Protocol API secret + the `visitor_type` event-scoped custom dimension) and where the secret lives at runtime are documented in [`ga-ssr-visitor-type.md`](ga-ssr-visitor-type.md). View the report in GA4 Explore: event `visitor_hit`, dimension `visitor_type`, metric Event count.

**Best-effort only:** a `User-Agent` is trivially spoofable, so this is for measurement, never access control. Spoof-proof identification (reverse DNS, published IP ranges) is intentionally out of scope.

### 6. Dynamic Sitemap

**Backend endpoint:** `GET /api/sitemap.xml`
Expand Down Expand Up @@ -309,7 +331,10 @@ Signals ranked by strength:
| `src/app/app.routes.server.ts` | SSR configuration (`RenderMode.Server` for `/salaries/overview`) |
| `src/app/modules/salaries/components/salaries-overview/` | Bot-friendly salary page (HTML tables, no auth) |
| `src/server/well-known.ts` | Static payloads for `/.well-known/api-catalog`, agent-skills index, and `site-overview` SKILL.md |
| `src/server.ts` | Link header, `/.well-known/*` route handlers, markdown content negotiation |
| `src/server/visitor-type.util.ts` | `User-Agent` classifier: human / search-crawler / ai-agent |
| `src/server/ga-measurement-protocol.util.ts` | Server-side GA4 `visitor_hit` send (Measurement Protocol) for bots |
| `src/app/services/visitor-analytics.service.ts` | Client-side GA4 `visitor_hit` (`visitor_type='human'`) per navigation |
| `src/server.ts` | Link header, `/.well-known/*` route handlers, visitor classification, markdown content negotiation |
| `web-api/src/Web.Api/Features/Sitemaps/` | Dynamic sitemap endpoint (`GET /api/sitemap.xml`) |

## Verification
Expand Down
11 changes: 11 additions & 0 deletions docs/interactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ Wired via `ngx-google-analytics`. The provider is **per-environment**:

The CSP in `src/server.ts` allows `googletagmanager.com`, `google-analytics.com`, and `*.analytics.google.com` for `script-src`, `connect-src`, and `img-src`.

### Visitor-type analytics (server-side Measurement Protocol)

To compare human vs search-crawler vs AI-agent traffic, both sides emit a custom GA4 event `visitor_hit` carrying a `visitor_type` param (event-scoped custom dimension registered in GA4):

- **Humans** — `VisitorAnalyticsService` (`src/app/services/visitor-analytics.service.ts`) fires `visitor_hit` with `visitor_type='human'` on each client-side navigation, via the existing `GoogleAnalyticsService`. Active only when GA is configured (production).
- **Bots** — client-side GA never runs for non-JS clients, so the SSR server sends `visitor_hit` for search-crawler / ai-agent requests via the **GA4 Measurement Protocol** — an outbound `POST https://www.google-analytics.com/mp/collect` from `src/server/ga-measurement-protocol.util.ts`. Fire-and-forget, env-gated on `GA_MP_API_SECRET` (no-op when unset). The bot User-Agent is deliberately NOT forwarded, otherwise GA4's automatic bot filter drops the hit. This is a server→server call, not subject to the browser CSP.

Manual GA4 setup (Measurement Protocol API secret + `visitor_type` custom dimension) is documented in [`ga-ssr-visitor-type.md`](ga-ssr-visitor-type.md).

## Express SSR server (`src/server.ts`)

In production the Angular app is served from this Node process. It is also an external surface in its own right, since it sets the headers that ship to browsers:
Expand All @@ -79,6 +88,7 @@ In production the Angular app is served from this Node process. It is also an ex
- **`/.well-known/agent-skills/index.json`** — Agent Skills Discovery RFC v0.2.0 index. Currently exposes one `site-overview` skill. The skill markdown is hashed at startup; bumping the markdown will change the `sha256` automatically.
- **`/.well-known/agent-skills/site-overview/SKILL.md`** — plain markdown describing site surfaces, served as `text/markdown`.
- **Markdown content negotiation** — when a client sends `Accept: text/markdown`, the SSR handler converts the rendered HTML to markdown via `turndown` and returns `Content-Type: text/markdown; charset=utf-8` with an `X-Markdown-Tokens` header (byte length, used as a coarse token proxy). Browsers without an explicit markdown accept still get HTML. The handler also sets `Vary: Accept` so caches keep the two representations separate.
- **Visitor classification** — every request is classified human / search-crawler / ai-agent (`src/server/visitor-type.util.ts`); the result is set on `res.locals.visitorType` and the `X-Visitor-Type` header, logged for non-human visitors, and (for bots) sent to GA4 via the Measurement Protocol (see Google Analytics section).
- **All other routes** — `Cache-Control: public, max-age=300, s-maxage=3600` and the Angular SSR engine.
- **Port** — `process.env.PORT || 4000`.

Expand All @@ -104,5 +114,6 @@ Several runtime dependencies inspect `window`, `document`, or `navigator` at mod
| Maintenance mode | `environment*.ts` → `isUnderMaintenance` | Hijacks router to `/maintenance` |
| Active environment | `angular.json` → `architect.build.configurations.<config>.fileReplacements` | Which `environment.ts` is bundled |
| SSR port | `process.env.PORT` (default `4000`) | Listening port |
| GA4 Measurement Protocol secret | `process.env.GA_MP_API_SECRET` (GitHub Actions secret → SSH deploy env → `docker-compose` substitution) | Enables server-side `visitor_hit` sends; unset = no-op |
| CSP / security headers | `src/server.ts` | Allowed third-party origins |
| Production bundle budgets | `angular.json` → `configurations.production.budgets` | Build fails if exceeded |
3 changes: 3 additions & 0 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Component, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { WebMcpService } from "@services/web-mcp.service";
import { VisitorAnalyticsService } from "@services/visitor-analytics.service";

@Component({
selector: "app-root",
Expand All @@ -16,9 +17,11 @@ export class AppComponent implements OnInit {
constructor(
private readonly router: Router,
private readonly webMcp: WebMcpService,
private readonly visitorAnalytics: VisitorAnalyticsService,
) {}

ngOnInit(): void {
this.webMcp.register();
this.visitorAnalytics.init();
}
}
2 changes: 1 addition & 1 deletion src/app/services/api.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from "@angular/core";
import { HttpClient, HttpHeaders, HttpParams } from "@angular/common/http";
import { Observable } from "rxjs";
import { environment } from "src/environments/environment";
import { environment } from "@environments/environment";

// responseType?: "arraybuffer" | "json" | "blob" | "text";
// observe?: "body" | "response" | "events";
Expand Down
Loading
Loading