Skip to content

Commit c10021d

Browse files
mikhail-dclclaude
andauthored
feat: latency SLI histograms (delta staleness, tick duration, drain cycle) (#32)
* feat: lock-free bucketed histogram for latency metrics Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: histogram instruments + collector plumbing for latency metrics Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: record per-tier delta staleness histogram in SendDelta (M1) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: tick-duration and drain-cycle timing metrics (M2, M3) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: native Prometheus histogram exposition for latency metrics Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: latency histograms on console dashboard + docs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: polish latency histograms per final review Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: honest nullable annotations on HistogramSnapshot arrays default(HistogramSnapshot) leaves both arrays null and that default is reachable (unset snapshot members reach PrometheusFormatter), so the non-nullable declaration lied; all consumers already guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: continent resolver over CC0 geo-whois-asn-country database Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: per-continent peer RTT instruments + Prometheus export Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: sample peer RTT by continent on the ENet thread (M4) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: peer RTT on dashboard, geo DB in Docker images, docs (M4) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: harden geo CSV parsing + final-review polish (M4) TryParse geo CSV numeric fields — skip + count corrupt rows instead of crashing startup on an unpinned re-fetch. Assert shared Merge bounds, fix RTT test comment + add index-label lock, point debug compose at /app/geodb. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: force LF checkout for shell scripts (Docker builds on Windows) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: predownload geo CSVs at local build time (M4) Local (non-Docker) runs had no geodb/ and resolved every peer to region="unknown". Add a FetchGeoDb MSBuild target that caches the two CSVs in packages/geodb and copies them next to the build output. Docker builds/containers (FetchGeoDb=false) and CI (CI=true) skip it, so the images' build-time ADD stays authoritative. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: source country-continent map from GeoNames countryInfo.txt (M4) Replace the six hardcoded continent constant tables with GeoNames countryInfo.txt, fetched through the existing channels (ADD in all three Dockerfiles, FetchGeoDb download + copy in the csproj). ParseCountryInfo reads field 8 (continent), MapContinentCode folds AN/garbage to UNKNOWN. Merge connectedPeers + continentByPeer into one ConnectedPeer(Peer, Continent) map. Finish the Continent UPPER_SNAKE rename and lock local/private addresses to UNKNOWN with explicit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: harden geo load + histogram merge (review P2s) Degrade to Empty on IO errors in LoadFromDirectory instead of crashing startup; make Merge's bounds guard always-on (ArgumentException); document the deliberate unpinned-download posture in docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 495adcd commit c10021d

32 files changed

Lines changed: 2054 additions & 11 deletions

.claude/skills/add-metric/SKILL.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,104 @@ per-type `RateTracker` dictionary and iterate in `TryConsumeSnapshot`.
261261

262262
---
263263

264+
## Pattern D: Histogram metric
265+
266+
For latency/duration values where the **distribution** matters, not just a count or a rate.
267+
Buckets are accumulated lock-free on the hot path; the dashboard shows value percentiles
268+
(window + lifetime) and Prometheus exposes native `_bucket`/`_sum`/`_count` series.
269+
270+
**Examples:** delta staleness per AoI tier, tick duration, outgoing drain-cycle duration.
271+
272+
**Reference implementation:** `delta_staleness` — trace `pulse.sim.delta_staleness_tier0_ms`
273+
end-to-end across the files below.
274+
275+
### 1. Declare the instrument — `src/DCLPulse/Metrics/PulseMetrics.*.cs`
276+
277+
```csharp
278+
// PulseMetrics.Simulation.cs
279+
public static readonly long[] MY_BUCKETS_MS = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512];
280+
281+
public static readonly Histogram<long> MY_LATENCY_MS =
282+
METER.CreateHistogram<long>("pulse.sim.my_latency_ms");
283+
```
284+
285+
Pick bucket bounds dense around the SLO region you care about. `Histogram<long>`, values in
286+
whole ms or µs (the collector keys on the instrument name, so units live only in the name).
287+
288+
### 2. Record at the source
289+
290+
```csharp
291+
PulseMetrics.Simulation.MY_LATENCY_MS.Record(elapsedMs);
292+
```
293+
294+
Record on the measuring thread. Wrap-safe unsigned subtraction for monotonic-clock deltas
295+
(see `RecordDeltaStaleness` in `PeerSimulation.cs`) — don't cast to signed before subtracting.
296+
297+
### 3. Accumulate in the collector — `src/DCLPulse/Metrics/MeterListenerMetricsCollector.cs`
298+
299+
- Add a `BucketHistogram` field seeded with the bucket bounds:
300+
```csharp
301+
private readonly BucketHistogram myLatency = new (PulseMetrics.Simulation.MY_BUCKETS_MS);
302+
```
303+
- Route the instrument in `OnLongMeasurement`:
304+
```csharp
305+
case "pulse.sim.my_latency_ms":
306+
myLatency.Record(value);
307+
break;
308+
```
309+
`BucketHistogram.Record` is lock-free (`Interlocked` per bucket) — no extra locking needed.
310+
311+
### 4. Expose on the snapshot — `src/DCLPulse/Metrics/MetricsSnapshot.cs`
312+
313+
```csharp
314+
public HistogramSnapshot MyLatencyMs { get; init; }
315+
```
316+
`HistogramSnapshot` (not `RateStats`) — an immutable per-bucket copy with a
317+
`Percentile(p)` helper. Percentiles are computed downstream.
318+
319+
### 5. Populate the snapshot — `MeterListenerMetricsCollector.TakeSnapshot`
320+
321+
```csharp
322+
MyLatencyMs = myLatency.Snapshot(),
323+
```
324+
325+
### 6. Emit Prometheus — `PrometheusFormatter.cs`
326+
327+
Native histogram exposition — header once, then one series per label set:
328+
```csharp
329+
WriteHistogramHeader(writer, "dcl_pulse_my_latency_ms", "My latency in ms");
330+
WriteHistogramSeries(writer, "dcl_pulse_my_latency_ms", snap.Simulation.MyLatencyMs, labels: null);
331+
```
332+
Emits `_bucket{le="…"}`, `_sum`, `_count`. Consumers run `histogram_quantile()`. Pass a
333+
`tier="0"`-style label string for per-variant series (see the `delta_staleness` calls).
334+
335+
### 7. Display on the console dashboard — `ConsoleDashboard.cs`
336+
337+
Use `HistogramTracker` (not `RateTracker`) — it adapts the cumulative `HistogramSnapshot`
338+
into `RateStats` where PerSec = recordings/s, Window = value percentiles over the delta
339+
since the previous snapshot, Lifetime = value percentiles over the cumulative buckets:
340+
```csharp
341+
private readonly HistogramTracker myLatencyTracker = new ();
342+
private readonly RateStatsView myLatency = new ();
343+
private readonly Sparkline myLatencySparkline = new (Enumerable.Repeat(0.0, SPARKLINE_MAX_SAMPLES));
344+
```
345+
In `TryConsumeSnapshot` — note the sparkline plots the **window P99** (the tail), not the rate:
346+
```csharp
347+
RateStats myLatencyStats = myLatencyTracker.Update(snap.Simulation.MyLatencyMs, elapsed);
348+
myLatency.Apply(myLatencyStats, v => v.ToString("N0"));
349+
ShiftSample(myLatencySparkline.Values, myLatencyStats.Window.P99);
350+
```
351+
Add a `RateStatsRow` to the `Latency` group in `BuildVisualTree`. The percentile columns then
352+
read as value distribution (ms/µs), not rate percentiles.
353+
354+
### 8. Document in `docs/metrics.md`
355+
356+
Add under `## Latency metrics`. Note the value-distribution semantics of the percentile columns,
357+
the expected range, the Prometheus `histogram_quantile()` guidance, and any exclusions (e.g.
358+
resync-path deltas are excluded from `delta_staleness`).
359+
360+
---
361+
264362
## Supporting types reference
265363

266364
| Type | Location | Purpose |

Dockerfile.debug

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ RUN apt-get update && apt-get install -y curl unzip procps \
44
&& curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l /vsdbg \
55
&& rm -rf /var/lib/apt/lists/*
66
COPY . .
7+
# Geo database — single fetch at image build; the app never downloads at runtime. Refreshes
8+
# with every image build. IP-range CSVs: geo-whois-asn-country (CC0,
9+
# https://github.com/sapics/ip-location-db). Country → continent mapping: GeoNames
10+
# countryInfo.txt (CC-BY 4.0, https://www.geonames.org).
11+
ADD https://cdn.jsdelivr.net/npm/@ip-location-db/geo-whois-asn-country/geo-whois-asn-country-ipv4-num.csv \
12+
https://cdn.jsdelivr.net/npm/@ip-location-db/geo-whois-asn-country/geo-whois-asn-country-ipv6-num.csv \
13+
https://download.geonames.org/export/dump/countryInfo.txt \
14+
/app/geodb/
15+
# The image already has fresh geo CSVs from the ADD above; the entrypoint's `dotnet run`
16+
# would otherwise trigger the csproj predownload at container start, so disable it.
17+
ENV FetchGeoDb=false
718
EXPOSE 7777/udp
819
EXPOSE 7743/udp
920
ENTRYPOINT ["dotnet", "run", "--project", "src/DCLPulse/DCLPulse.csproj", "--configuration", "Debug", "--property:GenerateProto=false"]

docker-compose.debug.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ services:
1313
DOTNET_ENVIRONMENT: Development
1414
Logging__LogLevel__Default: Debug
1515
Peers__ResyncWithDelta: false
16+
Transport__GeoDbDirectory: /app/geodb
1617
WebTransport__Enabled: true
1718
cap_add:
1819
- SYS_PTRACE

docs/metrics.md

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,85 @@ Counter of post-auth messages rejected for invalid fields (oversized `EmoteId`/`
260260

261261
---
262262

263+
## Latency metrics
264+
265+
Histogram-backed timing metrics for the simulation and outbound-drain hot paths. The collector holds raw per-bucket counts; the dashboard's percentile columns describe the **value distribution** (ms/µs) — Window over the buckets that filled since the previous 500 ms snapshot, Lifetime over the cumulative buckets — not a rate. The sparkline plots the window P99, the tail we care about.
266+
267+
### Δ Staleness T0 / T1 / T2 (ms)
268+
269+
Publish→fan-out staleness of `STATE_DELTA` per AoI tier — `MonotonicTime − target.ServerTick` measured at `SendDelta`. Each tier gets its own histogram because tiers fan out on different cadences (`tierDivisor`: T0 every tick, T1 every 2nd, T2 every 4th).
270+
271+
**Expected**: bounded by `tierDivisor × BaseTickMs` plus fan-out compute. T0 p99 is the **KR1.1 SLI**. Resync-path deltas are excluded by design — their target can be arbitrarily old when a subject idled after the client lost packets, which would pollute the histogram.
272+
273+
| Signal | Meaning |
274+
|---|---|
275+
| P99 within the tier budget | Normal — deltas fan out promptly after publish |
276+
| Sustained P99 above the tier budget | Tick overrun or input backlog — cross-check Tick Duration and Incoming Queue |
277+
| T0 climbing while T1/T2 flat | Every-tick fan-out is the bottleneck — AoI set for the hot tier grew |
278+
279+
### Tick Duration (µs)
280+
281+
`SimulateTick` wall time across workers, recorded per tick in `PeersManager.RecordTickDuration`.
282+
283+
**Expected**: well under `BaseTickMs × 1000`.
284+
285+
| Signal | Meaning |
286+
|---|---|
287+
| Flat, well under budget | Healthy — plenty of headroom in the tick |
288+
| Creeping toward `BaseTickMs × 1000` | CPU saturation or AoI fan-out growth — precursor to Tick Overruns |
289+
290+
### Tick Overruns
291+
292+
Count of ticks that exceeded `BaseTickMs`. Rendered as a rate row (per-second), not a histogram.
293+
294+
**Expected**: 0. Any sustained rate is an SLO breach — the simulation is not keeping tick cadence.
295+
296+
### Drain Cycle (µs)
297+
298+
Outbound drain-cycle duration on the ENet thread, non-empty cycles only (empty cycles are not recorded). Upper-bounds how long an outgoing message can wait for the ENet thread to service the queue.
299+
300+
| Signal | Meaning |
301+
|---|---|
302+
| Low, stable | ENet thread drains each burst well within a service loop |
303+
| Growth alongside Outgoing Queue depth | ENet thread saturated — outbound is falling behind; reduce fan-out or serialization cost |
304+
305+
**Prometheus**: these are exposed as native histograms — `dcl_pulse_delta_staleness_ms{tier="0|1|2"}`, `dcl_pulse_tick_duration_us`, `dcl_pulse_outgoing_drain_cycle_us` (Tick Overruns is the `dcl_pulse_tick_overruns_total` counter). Use `histogram_quantile()` over the `_bucket` series for fleet-level percentiles; the dashboard percentile columns show the local value distribution (window / lifetime), not rate percentiles.
306+
307+
### Peer RTT (ms)
308+
309+
Distribution of connected peers' smoothed round-trip time, bucketed by peer continent. ENet maintains `peer->roundTripTime` automatically from the reliable-channel ACK flow — no probe packets, no client cooperation. A sweep on the ENet thread samples **every connected peer every 5 s** (`RTT_SAMPLE_INTERVAL_MS`) and records each peer's current RTT into its region's histogram, so the distribution is **peer-weighted**: a region with more connected peers contributes proportionally more samples.
310+
311+
The `region` label is the continent resolved from the peer's IP against the geo-whois-asn-country (IP-allocation-registry) database described below. `unknown` folds together private/loopback IPs (local dev), addresses outside the loaded ranges or carrying unassigned country codes, and — when the database is absent from the image — every peer.
312+
313+
**500 ms seed caveat**: ENet seeds `roundTripTime` at 500 ms until the first reliable-channel ACK sample lands. A peer connected for less than one ACK round can therefore contribute that 500 ms seed to its first sweep entry — accepted noise at a 5 s cadence rather than a reason to track per-peer connect ages.
314+
315+
The console dashboard shows a single **Peer RTT (ms)** row that merges all seven per-continent histograms (`HistogramSnapshots.Merge`); the per-region breakdown is Grafana-only.
316+
317+
**Prometheus**: exposed as a native histogram `dcl_pulse_peer_rtt_ms` with one `region` label per continent (`af`, `as`, `eu`, `na`, `oc`, `sa`, `unknown`). Per-region percentile:
318+
319+
```promql
320+
histogram_quantile(0.5, sum by (le) (rate(dcl_pulse_peer_rtt_ms_bucket{region="as"}[5m])))
321+
```
322+
323+
| Signal | Meaning |
324+
|---|---|
325+
| A region's p50 stable and low | Peers there are close to the deployment — healthy |
326+
| One region's p50 ≫ the others | Distance-dominated latency — a case for a closer regional deployment |
327+
| A region's p99 ≫ its own p50 | Tail of poorly-connected peers (mobile, congested last mile) in that region |
328+
| `unknown` dominating with a real player population | Geo database missing from the image, or peers behind private/CGNAT egress the DB can't place |
329+
| Everything near 500 ms right after a connect burst | The ENet seed showing through before ACK samples arrive — transient, ignore |
330+
331+
**Data source**: two inputs, both fetched fresh at build time. IP-range → country comes from [geo-whois-asn-country](https://github.com/sapics/ip-location-db) (CC0, public domain), the `-num` CSV variants. Country → continent comes from [GeoNames `countryInfo.txt`](https://download.geonames.org/export/dump/countryInfo.txt) (CC-BY 4.0 — *"Contains data from GeoNames (geonames.org), licensed under CC BY 4.0"*), keyed on the ISO 3166-1 alpha-2 code with the continent read from the file's continent column (Antarctica folds to `unknown`). `ContinentResolver` loads all three once at startup from `Transport:GeoDbDirectory` (default `geodb`, resolved against the app base directory; absolute paths are used as-is). A missing mapping file or IPv4 CSV is tolerated — every peer then reports under `region="unknown"`.
332+
333+
The downloads are deliberately unpinned (no checksum, no version tag) so every image build ships current IP-allocation data, which is a stated requirement — geo ranges churn constantly and a stale pin would silently misplace peers. The risk of an unpinned fetch is contained on three fronts: the files are data-only, parsed into a lookup table with no execution path; the parser skips-and-counts malformed rows rather than throwing (see `ContinentResolver.ParseInto`); and an unusable or empty dataset — including a fetch that failed or returned garbage — degrades to `region="unknown"` with a warning rather than crashing startup.
334+
335+
Two ways the files get to that directory:
336+
337+
- **Docker**: each of the three Dockerfiles fetches the fresh IPv4 CSV, IPv6 CSV, and `countryInfo.txt` into the image's `geodb/` directory at build time via a single `ADD` — no runtime download. These freshly-fetched copies are authoritative for images.
338+
- **Local (non-Docker) builds**: the `DCLPulse.csproj` `FetchGeoDb` target predownloads the three files into the gitignored `packages/geodb/` cache (once) and copies them next to the build output, so local runs resolve regions without a Docker image. Delete `packages/geodb` to force a refresh. Offline builds warn and continue — the app then degrades to `region="unknown"`. The download is skipped in Docker builds and containers (`FetchGeoDb=false`) and on CI (`CI=true`), so it never runs where the `ADD`-provided or in-memory test copies already apply.
339+
340+
---
341+
263342
## Incoming Messages
264343

265344
Per-message-type rates for `ClientMessage` variants. Shows how many of each message type the server processes per second.
@@ -304,7 +383,8 @@ Per-message-type rates for `ServerMessage` variants. Shows the server's output c
304383

305384
## Adding new metrics
306385

307-
See the `/add-metric` skill (`/.claude/skills/add-metric/SKILL.md`) for step-by-step instructions covering three patterns:
386+
See the `/add-metric` skill (`/.claude/skills/add-metric/SKILL.md`) for step-by-step instructions covering four patterns:
308387
- **Pattern A**: Counter-based (System.Diagnostics.Metrics) — for hot-path values
309388
- **Pattern B**: Sampled (direct read) — for queue depths and gauges
310389
- **Pattern C**: Per-enum collection — for counting by message type or enum variant
390+
- **Pattern D**: Histogram — for latency/duration value distributions (percentiles over buckets)

src/DCLPulse/DCLPulse.csproj

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,34 @@
5050
<TargetPath>libenet.dylib</TargetPath>
5151
</None>
5252
</ItemGroup>
53+
54+
<!-- Geo database predownload for local runs. Docker images fetch their own fresh
55+
copies via ADD (which take precedence at runtime); Docker builds and CI set
56+
FetchGeoDb=false / CI=true so this never runs there. Delete packages/geodb
57+
to force a re-download. -->
58+
<PropertyGroup>
59+
<GeoDbCacheDir>$(MSBuildThisFileDirectory)..\..\packages\geodb\</GeoDbCacheDir>
60+
</PropertyGroup>
61+
62+
<Target Name="FetchGeoDb" BeforeTargets="Build"
63+
Condition="'$(FetchGeoDb)' != 'false' and '$(CI)' == ''">
64+
<DownloadFile SourceUrl="https://cdn.jsdelivr.net/npm/@ip-location-db/geo-whois-asn-country/geo-whois-asn-country-ipv4-num.csv"
65+
DestinationFolder="$(GeoDbCacheDir)"
66+
Condition="!Exists('$(GeoDbCacheDir)geo-whois-asn-country-ipv4-num.csv')"
67+
ContinueOnError="WarnAndContinue" />
68+
<DownloadFile SourceUrl="https://cdn.jsdelivr.net/npm/@ip-location-db/geo-whois-asn-country/geo-whois-asn-country-ipv6-num.csv"
69+
DestinationFolder="$(GeoDbCacheDir)"
70+
Condition="!Exists('$(GeoDbCacheDir)geo-whois-asn-country-ipv6-num.csv')"
71+
ContinueOnError="WarnAndContinue" />
72+
<DownloadFile SourceUrl="https://download.geonames.org/export/dump/countryInfo.txt"
73+
DestinationFolder="$(GeoDbCacheDir)"
74+
Condition="!Exists('$(GeoDbCacheDir)countryInfo.txt')"
75+
ContinueOnError="WarnAndContinue" />
76+
<ItemGroup>
77+
<GeoDbFiles Include="$(GeoDbCacheDir)*.csv" />
78+
<GeoDbFiles Include="$(GeoDbCacheDir)countryInfo.txt" />
79+
</ItemGroup>
80+
<Copy SourceFiles="@(GeoDbFiles)" DestinationFolder="$(OutDir)geodb"
81+
SkipUnchangedFiles="true" Condition="'@(GeoDbFiles)' != ''" />
82+
</Target>
5383
</Project>

src/DCLPulse/Dockerfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
22
ARG BUILD_CONFIGURATION=Release
3+
# The image fetches its own fresh geo CSVs via ADD in the final stage; disable the
4+
# csproj's local predownload so the build/publish steps never hit the network for them.
5+
ENV FetchGeoDb=false
36
WORKDIR /build
47
COPY ["src/DCLPulse/DCLPulse.csproj", "src/DCLPulse/"]
58
COPY ["src/DCLPulse/global.json", "src/DCLPulse/"]
@@ -27,6 +30,14 @@ ARG COMMIT_HASH=unknown
2730
ENV COMMIT_HASH=$COMMIT_HASH
2831
WORKDIR /app
2932
COPY --from=publish /app/publish .
33+
# Geo database — single fetch at image build; the app never downloads at runtime. Refreshes
34+
# with every image build. IP-range CSVs: geo-whois-asn-country (CC0,
35+
# https://github.com/sapics/ip-location-db). Country → continent mapping: GeoNames
36+
# countryInfo.txt (CC-BY 4.0, https://www.geonames.org).
37+
ADD https://cdn.jsdelivr.net/npm/@ip-location-db/geo-whois-asn-country/geo-whois-asn-country-ipv4-num.csv \
38+
https://cdn.jsdelivr.net/npm/@ip-location-db/geo-whois-asn-country/geo-whois-asn-country-ipv6-num.csv \
39+
https://download.geonames.org/export/dump/countryInfo.txt \
40+
/app/geodb/
3041
EXPOSE 7777/udp
3142
EXPOSE 7743/udp
3243
ENTRYPOINT ["dotnet", "DCLPulse.dll"]

src/DCLPulse/Dockerfile.dev-debug

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
2+
# The image fetches its own fresh geo CSVs via ADD in the final stage; disable the
3+
# csproj's local predownload so the build/publish steps never hit the network for them.
4+
ENV FetchGeoDb=false
25
WORKDIR /build
36
COPY ["src/DCLPulse/DCLPulse.csproj", "src/DCLPulse/"]
47
COPY ["src/DCLPulse/global.json", "src/DCLPulse/"]
@@ -34,6 +37,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl unzip proc
3437
&& printf 'Port 2222\nPermitRootLogin yes\nPermitEmptyPasswords yes\n' > /etc/ssh/sshd_config.d/debug.conf
3538
WORKDIR /app
3639
COPY --from=build /app/publish .
40+
# Geo database — single fetch at image build; the app never downloads at runtime. Refreshes
41+
# with every image build. IP-range CSVs: geo-whois-asn-country (CC0,
42+
# https://github.com/sapics/ip-location-db). Country → continent mapping: GeoNames
43+
# countryInfo.txt (CC-BY 4.0, https://www.geonames.org).
44+
ADD https://cdn.jsdelivr.net/npm/@ip-location-db/geo-whois-asn-country/geo-whois-asn-country-ipv4-num.csv \
45+
https://cdn.jsdelivr.net/npm/@ip-location-db/geo-whois-asn-country/geo-whois-asn-country-ipv6-num.csv \
46+
https://download.geonames.org/export/dump/countryInfo.txt \
47+
/app/geodb/
3748
EXPOSE 7777/udp
3849
EXPOSE 7743/udp
3950
EXPOSE 2222/tcp

0 commit comments

Comments
 (0)