WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 953dac4

Browse files
Flo4604chronark
andauthored
feat: add time retention parsing to clickhouse queries (#4389)
* feat: add time retention parsing to clickhouse queries * add logger * add tests * fmt * fmt * fmt * fmt * fmt * make rabbit happy * make rabbit happy * add upsert quota back * try longer sleep --------- Co-authored-by: Andreas Thomas <[email protected]>
1 parent 1219742 commit 953dac4

33 files changed

+1825
-127
lines changed

apps/docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@
268268
"errors/user/bad_request/invalid_analytics_table",
269269
"errors/user/bad_request/missing_required_header",
270270
"errors/user/bad_request/permissions_query_syntax_error",
271+
"errors/user/bad_request/query_range_exceeds_retention",
271272
"errors/user/bad_request/request_body_too_large",
272273
"errors/user/bad_request/request_body_unreadable",
273274
"errors/user/bad_request/request_timeout"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
title: "query_range_exceeds_retention"
3+
description: "QueryRangeExceedsRetention indicates the query attempts to access data older than the workspace's retention period."
4+
---
5+
6+
<Danger>`err:user:bad_request:query_range_exceeds_retention`</Danger>
7+
8+
## What does this error mean?
9+
10+
This error occurs when your analytics query attempts to access data older than your workspace's configured data retention period. By default, Unkey retains analytics data for **30 days**.
11+
12+
**Note**: If you don't provide a time filter in your query, the system automatically adds one to scope your query to the full retention period (e.g., `time >= now() - INTERVAL 30 DAY`). This error only occurs when you explicitly specify a time range that goes beyond the retention period.
13+
14+
## Common causes
15+
16+
1. **Querying historical data beyond retention**: Your query's time filter includes dates older than your retention period
17+
```sql
18+
-- If your retention is 30 days, this will fail if querying > 30 days ago
19+
SELECT COUNT(*) FROM key_verifications_v1 WHERE time >= 1234567890000
20+
```
21+
22+
## How to fix
23+
24+
1. **Add a time filter within retention**: Ensure your query only accesses data within your retention period
25+
```sql
26+
-- Query last 7 days (within 30-day retention)
27+
SELECT COUNT(*) FROM key_verifications_v1
28+
WHERE time >= now() - INTERVAL 7 DAY
29+
```
30+
31+
2. **Adjust your query range**: If you need to analyze trends, query within your available retention window
32+
```sql
33+
-- Query the full 30-day retention period
34+
SELECT COUNT(*) FROM key_verifications_v1
35+
WHERE time >= now() - INTERVAL 30 DAY
36+
```
37+
38+
## Need longer retention?
39+
40+
If your use case requires data retention beyond 30 days, please [contact our support team](https://unkey.com/support) to discuss upgrading your retention period. We can configure custom retention periods based on your needs.
41+
42+
## Related
43+
44+
- [Analytics documentation](/apis/features/analytics)
45+
- [Analytics query examples](/analytics)

go/apps/api/routes/v2_analytics_get_verifications/200_test.go

Lines changed: 241 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func Test200_Success(t *testing.T) {
2323
h.SetupAnalytics(workspace.ID)
2424
rootKey := h.CreateRootKey(workspace.ID, "api.*.read_analytics")
2525

26-
now := h.Clock.Now().UnixMilli()
26+
now := time.Now().UnixMilli()
2727

2828
// Buffer some key verifications
2929
for i := range 5 {
@@ -61,7 +61,7 @@ func Test200_Success(t *testing.T) {
6161
}
6262

6363
// Wait for buffered data to be available
64-
time.Sleep(2 * time.Second)
64+
time.Sleep(5 * time.Second)
6565

6666
res := testutil.CallRoute[Request, Response](h, route, headers, req)
6767
t.Logf("Status: %d, RawBody: %s", res.Status, res.RawBody)
@@ -85,7 +85,7 @@ func Test200_PermissionFiltersByApiId(t *testing.T) {
8585
// Create root key with permission ONLY for api1
8686
rootKey := h.CreateRootKey(workspace.ID, "api."+api1.ID+".read_analytics")
8787

88-
now := h.Clock.Now().UnixMilli()
88+
now := time.Now().UnixMilli()
8989

9090
// Buffer verifications for api1
9191
for i := range 3 {
@@ -166,7 +166,7 @@ func Test200_PermissionFiltersByKeySpaceId(t *testing.T) {
166166
// Create root key with permission ONLY for api1
167167
rootKey := h.CreateRootKey(workspace.ID, "api."+api1.ID+".read_analytics")
168168

169-
now := h.Clock.Now().UnixMilli()
169+
now := time.Now().UnixMilli()
170170

171171
// Buffer verifications for api1
172172
for i := range 3 {
@@ -239,3 +239,240 @@ func Test200_PermissionFiltersByKeySpaceId(t *testing.T) {
239239
require.Equal(c, float64(3), count)
240240
}, 30*time.Second, time.Second)
241241
}
242+
func Test200_QueryWithin30DaysRetention(t *testing.T) {
243+
h := testutil.NewHarness(t)
244+
245+
workspace := h.CreateWorkspace()
246+
api := h.CreateApi(seed.CreateApiRequest{
247+
WorkspaceID: workspace.ID,
248+
})
249+
h.SetupAnalytics(workspace.ID)
250+
rootKey := h.CreateRootKey(workspace.ID, "api.*.read_analytics")
251+
252+
now := time.Now().UnixMilli()
253+
254+
// Buffer verification from 7 days ago (within 30-day retention)
255+
h.ClickHouse.BufferKeyVerification(schema.KeyVerification{
256+
RequestID: uid.New(uid.RequestPrefix),
257+
Time: now - (7 * 24 * 60 * 60 * 1000), // 7 days ago
258+
WorkspaceID: workspace.ID,
259+
KeySpaceID: api.KeyAuthID.String,
260+
KeyID: uid.New(uid.KeyPrefix),
261+
Region: "us-west-1",
262+
Outcome: "VALID",
263+
IdentityID: "",
264+
Tags: []string{},
265+
})
266+
267+
route := &Handler{
268+
Logger: h.Logger,
269+
DB: h.DB,
270+
Keys: h.Keys,
271+
ClickHouse: h.ClickHouse,
272+
AnalyticsConnectionManager: h.AnalyticsConnectionManager,
273+
Caches: h.Caches,
274+
}
275+
h.Register(route)
276+
277+
headers := http.Header{
278+
"Authorization": []string{"Bearer " + rootKey},
279+
"Content-Type": []string{"application/json"},
280+
}
281+
282+
// Query last 7 days (within 30-day retention)
283+
req := Request{
284+
Query: "SELECT COUNT(*) as count FROM key_verifications_v1 WHERE time >= now() - INTERVAL 7 DAY",
285+
}
286+
287+
time.Sleep(5 * time.Second) // Wait for data
288+
289+
res := testutil.CallRoute[Request, Response](h, route, headers, req)
290+
require.Equal(t, 200, res.Status, "Query within retention should succeed")
291+
require.NotNil(t, res.Body)
292+
}
293+
294+
func Test200_QueryAtExact30DayRetentionLimit(t *testing.T) {
295+
h := testutil.NewHarness(t)
296+
297+
workspace := h.CreateWorkspace()
298+
h.SetupAnalytics(workspace.ID)
299+
rootKey := h.CreateRootKey(workspace.ID, "api.*.read_analytics")
300+
301+
route := &Handler{
302+
Logger: h.Logger,
303+
DB: h.DB,
304+
Keys: h.Keys,
305+
ClickHouse: h.ClickHouse,
306+
AnalyticsConnectionManager: h.AnalyticsConnectionManager,
307+
Caches: h.Caches,
308+
}
309+
h.Register(route)
310+
311+
headers := http.Header{
312+
"Authorization": []string{"Bearer " + rootKey},
313+
"Content-Type": []string{"application/json"},
314+
}
315+
316+
// Query exactly 30 days (at retention limit)
317+
req := Request{
318+
Query: "SELECT COUNT(*) as count FROM key_verifications_v1 WHERE time >= now() - INTERVAL 30 DAY",
319+
}
320+
321+
res := testutil.CallRoute[Request, Response](h, route, headers, req)
322+
require.Equal(t, 200, res.Status, "Query at retention limit should succeed")
323+
require.NotNil(t, res.Body)
324+
}
325+
326+
func Test200_QueryWithCustomRetention90Days(t *testing.T) {
327+
h := testutil.NewHarness(t)
328+
329+
workspace := h.CreateWorkspace()
330+
h.SetupAnalytics(workspace.ID, testutil.WithRetentionDays(90)) // 90-day retention
331+
rootKey := h.CreateRootKey(workspace.ID, "api.*.read_analytics")
332+
333+
route := &Handler{
334+
Logger: h.Logger,
335+
DB: h.DB,
336+
Keys: h.Keys,
337+
ClickHouse: h.ClickHouse,
338+
AnalyticsConnectionManager: h.AnalyticsConnectionManager,
339+
Caches: h.Caches,
340+
}
341+
h.Register(route)
342+
343+
headers := http.Header{
344+
"Authorization": []string{"Bearer " + rootKey},
345+
"Content-Type": []string{"application/json"},
346+
}
347+
348+
// Query 60 days (within 90-day retention)
349+
req := Request{
350+
Query: "SELECT COUNT(*) as count FROM key_verifications_v1 WHERE time >= now() - INTERVAL 60 DAY",
351+
}
352+
353+
res := testutil.CallRoute[Request, Response](h, route, headers, req)
354+
require.Equal(t, 200, res.Status, "Query within custom retention should succeed")
355+
require.NotNil(t, res.Body)
356+
}
357+
358+
func Test200_RLSWorkspaceIsolation(t *testing.T) {
359+
h := testutil.NewHarness(t)
360+
361+
// Create two separate workspaces
362+
workspace1 := h.CreateWorkspace()
363+
workspace2 := h.CreateWorkspace()
364+
365+
api1 := h.CreateApi(seed.CreateApiRequest{
366+
WorkspaceID: workspace1.ID,
367+
})
368+
api2 := h.CreateApi(seed.CreateApiRequest{
369+
WorkspaceID: workspace2.ID,
370+
})
371+
372+
// Setup analytics for both workspaces
373+
h.SetupAnalytics(workspace1.ID)
374+
h.SetupAnalytics(workspace2.ID)
375+
376+
rootKey1 := h.CreateRootKey(workspace1.ID, "api.*.read_analytics")
377+
378+
// Use actual current time for analytics data since ClickHouse's now() uses real time, not mock clock
379+
now := time.Now().UnixMilli()
380+
381+
// Buffer data for workspace 1
382+
for i := range 5 {
383+
h.ClickHouse.BufferKeyVerification(schema.KeyVerification{
384+
RequestID: uid.New(uid.RequestPrefix),
385+
Time: now - int64(i*1000),
386+
WorkspaceID: workspace1.ID,
387+
KeySpaceID: api1.KeyAuthID.String,
388+
KeyID: uid.New(uid.KeyPrefix),
389+
Region: "us-west-1",
390+
Outcome: "VALID",
391+
IdentityID: "",
392+
Tags: []string{},
393+
})
394+
}
395+
396+
// Buffer data for workspace 2 (should NOT be accessible by workspace1's key)
397+
for i := range 10 {
398+
h.ClickHouse.BufferKeyVerification(schema.KeyVerification{
399+
RequestID: uid.New(uid.RequestPrefix),
400+
Time: now - int64(i*1000),
401+
WorkspaceID: workspace2.ID,
402+
KeySpaceID: api2.KeyAuthID.String,
403+
KeyID: uid.New(uid.KeyPrefix),
404+
Region: "us-east-1",
405+
Outcome: "VALID",
406+
IdentityID: "",
407+
Tags: []string{},
408+
})
409+
}
410+
411+
route := &Handler{
412+
Logger: h.Logger,
413+
DB: h.DB,
414+
Keys: h.Keys,
415+
ClickHouse: h.ClickHouse,
416+
AnalyticsConnectionManager: h.AnalyticsConnectionManager,
417+
Caches: h.Caches,
418+
}
419+
h.Register(route)
420+
421+
headers := http.Header{
422+
"Authorization": []string{"Bearer " + rootKey1},
423+
"Content-Type": []string{"application/json"},
424+
}
425+
426+
// Query all verifications - should only return workspace1's data due to RLS
427+
req := Request{
428+
Query: "SELECT COUNT(*) as count FROM key_verifications_v1 WHERE time >= now() - INTERVAL 1 DAY",
429+
}
430+
431+
time.Sleep(10 * time.Second) // Wait for data to be flushed to ClickHouse
432+
433+
res := testutil.CallRoute[Request, Response](h, route, headers, req)
434+
require.Equal(t, 200, res.Status)
435+
require.NotNil(t, res.Body)
436+
require.Len(t, res.Body.Data, 1)
437+
438+
// Verify only workspace1's data is returned (5 verifications), not workspace2's (10)
439+
count, ok := res.Body.Data[0]["count"]
440+
require.True(t, ok)
441+
require.Equal(t, float64(5), count, "RLS should filter to only workspace1's data")
442+
}
443+
444+
func Test200_QueryWithoutTimeFilter_AutoAddsFilter(t *testing.T) {
445+
h := testutil.NewHarness(t)
446+
447+
workspace := h.CreateWorkspace()
448+
h.SetupAnalytics(workspace.ID)
449+
rootKey := h.CreateRootKey(workspace.ID, "api.*.read_analytics")
450+
451+
route := &Handler{
452+
Logger: h.Logger,
453+
DB: h.DB,
454+
Keys: h.Keys,
455+
ClickHouse: h.ClickHouse,
456+
AnalyticsConnectionManager: h.AnalyticsConnectionManager,
457+
Caches: h.Caches,
458+
}
459+
h.Register(route)
460+
461+
headers := http.Header{
462+
"Authorization": []string{"Bearer " + rootKey},
463+
"Content-Type": []string{"application/json"},
464+
}
465+
466+
// Query without time filter - should auto-add time >= now() - INTERVAL 30 DAY
467+
req := Request{
468+
Query: "SELECT COUNT(*) as count FROM key_verifications_v1",
469+
}
470+
471+
res := testutil.CallRoute[Request, Response](h, route, headers, req)
472+
if res.Status != 200 {
473+
t.Logf("Response status: %d", res.Status)
474+
t.Logf("Response body: %s", res.RawBody)
475+
}
476+
require.Equal(t, 200, res.Status, "Query without time filter should succeed with auto-added filter")
477+
require.NotNil(t, res.Body)
478+
}

0 commit comments

Comments
 (0)