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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
12 changes: 12 additions & 0 deletions api/db/mongodb/migrations/026_statistics_clear_legacy.up.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[
{
"delete": "statistics",
"deletes": [
{
"q": {},
"limit": 0
}
],
"writeConcern": { "w": "majority" }
}
]
12 changes: 12 additions & 0 deletions api/db/mongodb/migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ the target environment: case-collision groups must be zero, and emails must be A
without surrounding whitespace (`$toLower` is ASCII-only; the migration does not trim).
Audit queries are in the PR that introduced the migration.

### Migration 026 (statistics clear)

Clears the legacy per-profile `statistics` time-series collection. Query statistics are
now service-wide and live in `service_statistics` (a regular collection the proxy upserts
into, one document per PoP and hour, no TTL);
per-profile statistics return with the Analytics page in a new shape. `delete` with an empty
filter, not `drop`, so a fresh database without the collection succeeds; an empty-filter
delete on a time-series collection needs MongoDB ≥ 7.0. Idempotent; the down migration is
a no-op. Deploy note: proxies still running the previous release between the DCN and DFN
restarts may recreate `statistics` as a plain collection; after the DFN restart, drop it if
`db.statistics.countDocuments({})` is non-zero.

### Query logs collections

Note: Query logs time-series collections are created by the proxy service. Their only index is the `{profile_id, timestamp}` meta+time index MongoDB creates automatically on time-series creation (≥6.3) — no code creates query-log indexes explicitly (verified against prod, moddns-shadow#688).
13 changes: 7 additions & 6 deletions app/src/pages/legal/PrivacyPolicy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default function PrivacyPolicy() {
<div className="space-y-6">
<div className="mb-6">
<p className="text-sm text-[var(--shadcn-ui-app-muted-foreground)] mb-4">
Last updated: Mar 23, 2026
Last updated: Sep 17, 2026
</p>
</div>

Expand Down Expand Up @@ -108,9 +108,9 @@ export default function PrivacyPolicy() {
</p>
<ul className="list-disc pl-6 space-y-2 text-[var(--shadcn-ui-app-foreground)] leading-relaxed">
<li>DNS queries (e.g., which websites you visit)</li>
<li>Timestamps of DNS resolutions</li>
<li>Your IP addresses</li>
<li>Device information or identifiers</li>
<li>Timestamps of DNS resolutions with user/profile attribution</li>
</ul>
<p className="text-[var(--shadcn-ui-app-foreground)] leading-relaxed mt-4">
For more information on what is logged when you optionally enable "Query Logs", see the next section.
Expand All @@ -125,13 +125,14 @@ export default function PrivacyPolicy() {

<h3 className="text-lg font-semibold mb-2">a) Default setting (Query Logs Disabled)</h3>
<p className="text-[var(--shadcn-ui-app-foreground)] leading-relaxed mb-4">
When query logging is turned off, all queries are processed entirely in memory and are never written to disk. We log no information about your usage of the DNS resolver, with one exception:
When query logging is turned off, all queries are processed entirely in memory and are never written to disk.
</p>
<p className="text-[var(--shadcn-ui-app-foreground)] leading-relaxed mb-4">
We store a total count of DNS requests processed by your profile. This is a simple counter and contains no specific details about your activity. Example of data stored:
The only DNS activity data we keep is anonymous service-wide statistics: the number of queries, blocked queries and DNSSEC-validated queries handled by each server location, added up across all users with hourly timestamps. These counters contain no profile, device or client reference. Example of data stored:
</p>
<pre className="bg-[var(--shadcn-ui-app-background)] border border-[var(--shadcn-ui-app-border)] rounded-md p-4 text-sm text-[var(--shadcn-ui-app-foreground)] opacity-80 overflow-x-auto mb-4">{`"profile_id": "ju8eamnqfn"
"queries": { "total": 244 }`}</pre>
<pre className="bg-[var(--shadcn-ui-app-background)] border border-[var(--shadcn-ui-app-border)] rounded-md p-4 text-sm text-[var(--shadcn-ui-app-foreground)] opacity-80 overflow-x-auto mb-4">{`"timestamp": "2026-09-16T10:00:00Z"
"pop": "ams1"
"queries": { "total": 18342, "blocked": 2917, "dnssec": 520 }`}</pre>

<h3 className="text-lg font-semibold mb-2">b) With Query Logs Enabled</h3>
<p className="text-[var(--shadcn-ui-app-foreground)] leading-relaxed mb-4">
Expand Down
6 changes: 3 additions & 3 deletions proxy/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ SERVER_NAME="moddns.dev"
DNS_CHECK_DOMAIN="test.moddns.dev"
DNS_CHECK_PORT="53"
MAX_GOROUTINES=10000
POP_NAME=dev1

### UPSTREAM CONFIG
# Format: name=address,name=address,...
Expand Down Expand Up @@ -56,9 +57,8 @@ EMITTER_SINK_DB_AUTH_SOURCE="admin"
COLLECTOR_QUERY_LOGS_BATCH_SIZE=100
COLLECTOR_QUERY_LOGS_BATCH_INTERVAL=10s

COLLECTOR_STATISTICS_BATCH_SIZE=1000
COLLECTOR_STATISTICS_BATCH_INTERVAL=30s

COLLECTOR_SERVICE_STATISTICS_BATCH_SIZE=1000
COLLECTOR_SERVICE_STATISTICS_BATCH_INTERVAL=30s

### SENTRY CONFIG
SENTRY_DSN=""
Expand Down
3 changes: 2 additions & 1 deletion proxy/collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,14 @@ func NewCollector(collectorCfg config.CollectorConfig, collectorType string, sto
batchSize := collectorCfg.GetBatchSize()
freq := collectorCfg.GetFrequency()
statsChan := make(chan (model.EventStatistics), batchSize)
return &StatisticsCollector{
return &ServiceStatisticsCollector{
Type: collectorType,
StopChan: stopChan,
Frequency: freq,
BatchSize: batchSize,
StatsChan: statsChan,
Emitter: emitter,
Pop: collectorCfg.GetPopName(),
}, nil
default:
return nil, errors.New("unknown collector type")
Expand Down
108 changes: 108 additions & 0 deletions proxy/collector/service_statistics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package collector

import (
"context"
"sort"
"time"

"github.com/ivpn/dns/proxy/collector/channel"
"github.com/ivpn/dns/proxy/emitter"
"github.com/ivpn/dns/proxy/model"
"github.com/rs/zerolog/log"
)

// ServiceStatisticsCollector sums per-query counter events into one
// service-wide document per hour for this PoP and hands the open documents to
// the emitter on every flush. Only the Collect goroutine touches the
// accumulator, so no locking is needed.
type ServiceStatisticsCollector struct {
Type string
BatchSize int
StopChan chan struct{}
Frequency time.Duration
StatsChan chan model.EventStatistics
Emitter emitter.Emitter
Pop string
// Now places events into their hour; tests inject a clock.
Now func() time.Time

buckets map[time.Time]*model.ServiceStatistics
counter int
}

func (c *ServiceStatisticsCollector) Collect() error {
ticker := time.NewTicker(c.Frequency)
defer ticker.Stop()
for {
select {
case event, ok := <-c.StatsChan:
if !ok {
log.Debug().Msg("Channel closed or empty")
continue
}
c.add(event)
if c.counter >= c.BatchSize {
c.flush("batch_size")
}
case <-ticker.C:
if c.counter == 0 {
log.Trace().Msg("Postpone stats event emission")
continue
}
c.flush("frequency")
case <-c.StopChan:
log.Info().Msg("Stopping statistics collector")
return nil
}
}
}

func (c *ServiceStatisticsCollector) GetChannel() channel.CollectorChannel {
return channel.EventStatisticsChannel{Channel: c.StatsChan}
}

func (c *ServiceStatisticsCollector) now() time.Time {
if c.Now != nil {
return c.Now()
}
return time.Now()
}

// add sums one event into the document for the hour that is open now.
func (c *ServiceStatisticsCollector) add(event model.EventStatistics) {
if c.buckets == nil {
c.buckets = make(map[time.Time]*model.ServiceStatistics)
}
now := c.now()
bucket := model.BucketStart(now)
doc, ok := c.buckets[bucket]
if !ok {
doc = model.NewServiceStatistics(c.Pop, now)
c.buckets[bucket] = doc
}
doc.Aggregate(event)
c.counter++
}

// flush emits every open document as an increment and resets. A failed emit
// is logged and the counters are dropped, as for query logs.
func (c *ServiceStatisticsCollector) flush(trigger string) {
if len(c.buckets) == 0 {
return
}
batch := make([]model.ServiceStatistics, 0, len(c.buckets))
for _, doc := range c.buckets {
batch = append(batch, *doc)
}
sort.Slice(batch, func(i, j int) bool { return batch[i].Timestamp.Before(batch[j].Timestamp) })

ctx, cancel := context.WithTimeout(context.Background(), EmitTimeout)
defer cancel()
log.Info().Str("event_type", c.Type).Str("trigger", trigger).Int("events_number", len(batch)).Msg("Emitting stats events batch")
if err := c.Emitter.EmitServiceStatistics(ctx, batch); err != nil {
log.Error().Err(err).Msg("Failed to emit stats events")
}

c.buckets = make(map[time.Time]*model.ServiceStatistics)
c.counter = 0
}
140 changes: 140 additions & 0 deletions proxy/collector/service_statistics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package collector

import (
"testing"
"time"

"github.com/ivpn/dns/proxy/mocks"
"github.com/ivpn/dns/proxy/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func newTestStatsCollector(t *testing.T, emitter *mocks.Emitter, batchSize int, freq time.Duration) *ServiceStatisticsCollector {
t.Helper()
return &ServiceStatisticsCollector{
Type: model.TYPE_STATISTICS,
BatchSize: batchSize,
Frequency: freq,
StopChan: make(chan struct{}),
StatsChan: make(chan model.EventStatistics, batchSize),
Emitter: emitter,
Pop: "ams1",
}
}

func evt(q model.Queries) model.EventStatistics {
return model.EventStatistics{Queries: q}
}

// specRef: proxy-statistics-behaviour.md #Y5 #Y6
func TestServiceStatisticsCollector_SumsEventsIntoHourDocuments(t *testing.T) {
emitter := mocks.NewEmitter(t)
c := newTestStatsCollector(t, emitter, 100, time.Minute)
clock := time.Date(2026, 9, 17, 13, 58, 30, 0, time.UTC)
c.Now = func() time.Time { return clock }

c.add(evt(model.Queries{Total: 1}))
c.add(evt(model.Queries{Total: 1, Blocked: 1}))
clock = clock.Add(90 * time.Second) // crosses into the next hour
c.add(evt(model.Queries{Total: 1, DNSSEC: 1}))

var got []model.ServiceStatistics
emitter.On("EmitServiceStatistics", mock.Anything, mock.MatchedBy(func(batch []model.ServiceStatistics) bool {
got = batch
return true
})).Return(nil).Once()

c.flush("test")

require.Len(t, got, 2, "one document per hour touched in this flush")
assert.Equal(t, "ams1:2026-09-17T13", got[0].ID)
assert.Equal(t, model.Queries{Total: 2, Blocked: 1}, got[0].Queries)
assert.Equal(t, "ams1:2026-09-17T14", got[1].ID)
assert.Equal(t, model.Queries{Total: 1, DNSSEC: 1}, got[1].Queries)
for _, doc := range got {
assert.Equal(t, "ams1", doc.Pop)
assert.Zero(t, doc.Timestamp.Minute()+doc.Timestamp.Second()+doc.Timestamp.Nanosecond(), "hour start only")
}
}

// specRef: proxy-statistics-behaviour.md #Y8
func TestServiceStatisticsCollector_FlushResetsAccumulator(t *testing.T) {
emitter := mocks.NewEmitter(t)
c := newTestStatsCollector(t, emitter, 100, time.Minute)
c.Now = func() time.Time { return time.Date(2026, 9, 17, 13, 10, 0, 0, time.UTC) }

emitter.On("EmitServiceStatistics", mock.Anything, mock.MatchedBy(func(batch []model.ServiceStatistics) bool {
return len(batch) == 1 && batch[0].Queries.Total == 3
})).Return(nil).Once()

for i := 0; i < 3; i++ {
c.add(evt(model.Queries{Total: 1}))
}
c.flush("test")

assert.Empty(t, c.buckets)
assert.Zero(t, c.counter)
c.flush("test") // nothing pending: no emit (the mock would fail on a second call)
}

// specRef: proxy-statistics-behaviour.md #Y8
func TestServiceStatisticsCollector_Collect_FlushesOnBatchSizeAndInterval(t *testing.T) {
emitter := mocks.NewEmitter(t)
c := newTestStatsCollector(t, emitter, 2, 50*time.Millisecond)

batches := make(chan []model.ServiceStatistics, 4)
emitter.On("EmitServiceStatistics", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
batches <- args.Get(1).([]model.ServiceStatistics)
}).Return(nil)

done := make(chan struct{})
go func() { _ = c.Collect(); close(done) }()

// Two events reach the batch size and flush immediately.
c.StatsChan <- evt(model.Queries{Total: 1})
c.StatsChan <- evt(model.Queries{Total: 1, Blocked: 1})
select {
case batch := <-batches:
require.Len(t, batch, 1)
assert.Equal(t, model.Queries{Total: 2, Blocked: 1}, batch[0].Queries)
case <-time.After(time.Second):
t.Fatal("batch-size flush did not happen")
}

// One event below the batch size is flushed by the ticker.
c.StatsChan <- evt(model.Queries{Total: 1})
select {
case batch := <-batches:
require.Len(t, batch, 1)
assert.Equal(t, model.Queries{Total: 1}, batch[0].Queries)
case <-time.After(time.Second):
t.Fatal("interval flush did not happen")
}

close(c.StopChan)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("collector did not stop")
}
}

// specRef: proxy-statistics-behaviour.md #Y9
func TestServiceStatisticsCollector_EmitErrorDropsBatchAndContinues(t *testing.T) {
emitter := mocks.NewEmitter(t)
c := newTestStatsCollector(t, emitter, 100, time.Minute)
c.Now = func() time.Time { return time.Date(2026, 9, 17, 13, 10, 0, 0, time.UTC) }

emitter.On("EmitServiceStatistics", mock.Anything, mock.Anything).Return(assert.AnError).Once()
c.add(evt(model.Queries{Total: 1}))
assert.NotPanics(t, func() { c.flush("test") })
assert.Empty(t, c.buckets, "a failed batch is dropped, not retried")

emitter.On("EmitServiceStatistics", mock.Anything, mock.MatchedBy(func(batch []model.ServiceStatistics) bool {
return len(batch) == 1 && batch[0].Queries.Total == 1
})).Return(nil).Once()
c.add(evt(model.Queries{Total: 1}))
c.flush("test")
}
Loading
Loading