From f7de00c328f161153ff3f01e0596e3551f74f1b7 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 20 Jan 2026 10:53:36 -0800 Subject: [PATCH 1/2] Implement middleware to track uniques per app --- api/metrics_middleware.go | 187 ++++++++++++++++-- api/server.go | 1 + api/v1_metrics_apps_unique.go | 113 +++++++++++ api/v1_metrics_apps_unique_test.go | 50 +++++ .../0188_api_metrics_apps_unique.sql | 20 ++ 5 files changed, 358 insertions(+), 13 deletions(-) create mode 100644 api/v1_metrics_apps_unique.go create mode 100644 api/v1_metrics_apps_unique_test.go create mode 100644 ddl/migrations/0188_api_metrics_apps_unique.sql diff --git a/api/metrics_middleware.go b/api/metrics_middleware.go index dd141345..c76fb035 100644 --- a/api/metrics_middleware.go +++ b/api/metrics_middleware.go @@ -3,12 +3,15 @@ package api import ( "context" "runtime" + "sync" "time" "api.audius.co/hll" "api.audius.co/utils" + "github.com/axiomhq/hyperloglog" "github.com/gofiber/fiber/v2" fiberutils "github.com/gofiber/fiber/v2/utils" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/maypok86/otter" "go.uber.org/zap" @@ -22,9 +25,11 @@ type MetricsCollector struct { flushTimer *time.Ticker stopCh chan struct{} - appMetrics otter.Cache[string, *AppMetricsData] - routeMetrics otter.Cache[string, *RouteMetricsData] - countMetrics *hll.HLL + appMetrics otter.Cache[string, *AppMetricsData] + routeMetrics otter.Cache[string, *RouteMetricsData] + countMetrics *hll.HLL + appUniqueMetrics map[string]*hll.HLL + appUniqueMu sync.RWMutex } // AppMetricsData holds request count data for a specific app identifier @@ -75,13 +80,14 @@ func NewMetricsCollector(logger *zap.Logger, writePool *pgxpool.Pool) *MetricsCo } collector := &MetricsCollector{ - logger: logger.With(zap.String("component", "MetricsCollector")), - writePool: writePool, - appMetrics: appMetricsCache, - routeMetrics: routeMetricsCache, - countMetrics: countMetricsAggregator, - flushTimer: time.NewTicker(flushTimer), - stopCh: make(chan struct{}), + logger: logger.With(zap.String("component", "MetricsCollector")), + writePool: writePool, + appMetrics: appMetricsCache, + routeMetrics: routeMetricsCache, + countMetrics: countMetricsAggregator, + appUniqueMetrics: make(map[string]*hll.HLL), + flushTimer: time.NewTicker(flushTimer), + stopCh: make(chan struct{}), } // Start the flush routine @@ -97,11 +103,14 @@ func (rmc *MetricsCollector) Middleware() fiber.Handler { apiKey := c.Query("api_key") appName := c.Query("app_name") + ipAddress := utils.GetIP(c) + // Only record if we have some identifier if apiKey != "" || appName != "" { rmc.recordAppMetric( fiberutils.CopyString(apiKey), fiberutils.CopyString(appName), + ipAddress, ) } @@ -115,8 +124,7 @@ func (rmc *MetricsCollector) Middleware() fiber.Handler { ) } - // Extract IP address for unique tracking - ipAddress := utils.GetIP(c) + // Extract IP address for unique tracking (global) if ipAddress != "" { rmc.recordCountMetric(ipAddress) } @@ -126,7 +134,7 @@ func (rmc *MetricsCollector) Middleware() fiber.Handler { } // Increments the request count for a given app identifier -func (rmc *MetricsCollector) recordAppMetric(apiKey, appName string) { +func (rmc *MetricsCollector) recordAppMetric(apiKey, appName, ipAddress string) { // Prioritize api_key over app_name as identifier identifier := apiKey if identifier == "" { @@ -147,6 +155,26 @@ func (rmc *MetricsCollector) recordAppMetric(apiKey, appName string) { data.RequestCount++ data.LastSeen = lastSeen rmc.appMetrics.Set(identifier, data) + + // Record IP address to app-specific HLL sketch for unique user tracking + if ipAddress != "" { + rmc.appUniqueMu.Lock() + appHLL, exists := rmc.appUniqueMetrics[identifier] + if !exists { + // Create new HLL instance for this app + var err error + appHLL, err = hll.NewHLL(rmc.logger, rmc.writePool, "api_metrics_apps_unique", 12) + if err != nil { + rmc.logger.Error("Failed to create app unique HLL", zap.Error(err), zap.String("identifier", identifier)) + rmc.appUniqueMu.Unlock() + return + } + rmc.appUniqueMetrics[identifier] = appHLL + } + rmc.appUniqueMu.Unlock() + + appHLL.Record(ipAddress) + } } // Increments the request count for a given route pattern @@ -210,6 +238,23 @@ func (rmc *MetricsCollector) flushMetrics() { // Get HLL sketch copy currentHLL, currentTotalRequests := rmc.countMetrics.GetSketchCopy() + type AppUniqueData struct { + Identifier string + Sketch *hyperloglog.Sketch + TotalCount int64 + } + appUniqueData := make(map[string]*AppUniqueData) + rmc.appUniqueMu.Lock() + for identifier, appHLL := range rmc.appUniqueMetrics { + sketchCopy, totalCount := appHLL.GetSketchCopy() + appUniqueData[identifier] = &AppUniqueData{ + Identifier: identifier, + Sketch: sketchCopy, + TotalCount: totalCount, + } + } + rmc.appUniqueMu.Unlock() + // Begin transaction tx, err := rmc.writePool.Begin(ctx) if err != nil { @@ -295,6 +340,122 @@ func (rmc *MetricsCollector) flushMetrics() { } } + // Flush app unique metrics + if len(appUniqueData) > 0 { + appUniqueUpserted := 0 + for _, data := range appUniqueData { + if data.Sketch == nil { + continue + } + + // Clone the sketch to avoid modifying the original + newSketch := data.Sketch.Clone() + if newSketch == nil { + continue + } + + var existingSketchData []byte + var existingCount int64 + query := ` + SELECT hll_sketch, total_count + FROM api_metrics_apps_unique + WHERE date = $1 AND app_name = $2 + FOR UPDATE` + err = tx.QueryRow(ctx, query, date, data.Identifier).Scan(&existingSketchData, &existingCount) + + if err != nil && err != pgx.ErrNoRows { + rmc.logger.Error("Failed to query existing app unique metrics", + zap.Error(err), + zap.String("identifier", data.Identifier)) + continue + } + + var finalSketchData []byte + var finalTotalCount int64 + var finalUniqueCount int64 + + if err == pgx.ErrNoRows { + // No existing row - use new sketch as-is + var marshalErr error + finalSketchData, marshalErr = newSketch.MarshalBinary() + if marshalErr != nil { + rmc.logger.Error("Failed to marshal new sketch", + zap.Error(marshalErr), + zap.String("identifier", data.Identifier)) + continue + } + finalTotalCount = data.TotalCount + finalUniqueCount = int64(newSketch.Estimate()) + } else { + // Row exists - merge sketches + if existingSketchData != nil { + // Merge with existing sketch + existingSketch, unmarshalErr := hll.UnmarshalSketch(existingSketchData, 12) + if unmarshalErr != nil { + rmc.logger.Error("Failed to unmarshal existing sketch", + zap.Error(unmarshalErr), + zap.String("identifier", data.Identifier)) + continue + } + + if mergeErr := existingSketch.Merge(newSketch); mergeErr != nil { + rmc.logger.Error("Failed to merge sketches", + zap.Error(mergeErr), + zap.String("identifier", data.Identifier)) + continue + } + + var marshalErr error + finalSketchData, marshalErr = existingSketch.MarshalBinary() + if marshalErr != nil { + rmc.logger.Error("Failed to marshal merged sketch", + zap.Error(marshalErr), + zap.String("identifier", data.Identifier)) + continue + } + finalUniqueCount = int64(existingSketch.Estimate()) + } else { + // Row exists but sketch is NULL - use new sketch + var marshalErr error + finalSketchData, marshalErr = newSketch.MarshalBinary() + if marshalErr != nil { + rmc.logger.Error("Failed to marshal new sketch", + zap.Error(marshalErr), + zap.String("identifier", data.Identifier)) + continue + } + finalUniqueCount = int64(newSketch.Estimate()) + } + finalTotalCount = existingCount + data.TotalCount + } + + // Use INSERT ... ON CONFLICT for atomic upsert (same pattern as api_metrics_apps) + upsertQuery := ` + INSERT INTO api_metrics_apps_unique (date, app_name, hll_sketch, total_count, unique_count, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (date, app_name) + DO UPDATE SET + hll_sketch = EXCLUDED.hll_sketch, + total_count = EXCLUDED.total_count, + unique_count = EXCLUDED.unique_count, + updated_at = NOW()` + + _, err = tx.Exec(ctx, upsertQuery, date, data.Identifier, finalSketchData, finalTotalCount, finalUniqueCount) + if err != nil { + rmc.logger.Error("Failed to upsert app unique metrics", + zap.Error(err), + zap.String("identifier", data.Identifier)) + continue + } + + appUniqueUpserted++ + } + + rmc.logger.Debug("Flushed app unique metrics", + zap.Int("upserted", appUniqueUpserted), + zap.Int("total", len(appUniqueData))) + } + // Commit transaction if err := tx.Commit(ctx); err != nil { rmc.logger.Error("Failed to commit metrics transaction", zap.Error(err)) diff --git a/api/server.go b/api/server.go index a2b0fc37..af091b6f 100644 --- a/api/server.go +++ b/api/server.go @@ -524,6 +524,7 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/metrics/total_artists", app.v1MetricsTotalArtists) g.Get("/metrics/total_wallets", app.v1MetricsTotalWallets) g.Get("/metrics/aggregates/apps/:time_range", app.v1MetricsApps) + g.Get("/metrics/aggregates/apps/:time_range/unique", app.v1MetricsAppsUnique) g.Get("/metrics/aggregates/routes/:time_range", app.v1MetricsRoutes) g.Get("/metrics/aggregates/routes/trailing/:time_range", app.v1MetricsRoutesTrailing) diff --git a/api/v1_metrics_apps_unique.go b/api/v1_metrics_apps_unique.go new file mode 100644 index 00000000..6b882904 --- /dev/null +++ b/api/v1_metrics_apps_unique.go @@ -0,0 +1,113 @@ +package api + +import ( + "fmt" + "sort" + + "api.audius.co/hll" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" +) + +type AppUniqueMetric struct { + Name string `json:"name"` + UniqueCount int64 `json:"unique_count"` +} + +func (app *ApiServer) v1MetricsAppsUnique(c *fiber.Ctx) error { + queryParams := GetMetricsAppsQueryParams{} + if err := app.ParseAndValidateQueryParams(c, &queryParams); err != nil { + return err + } + routeParams := GetAggregateAppMetricsRouteParams{} + if err := c.ParamsParser(&routeParams); err != nil { + return err + } + + var dateRangeClause string + switch routeParams.TimeRange { + case "week": + dateRangeClause = "date >= CURRENT_DATE - INTERVAL '7 days' AND date < CURRENT_DATE" + case "month": + dateRangeClause = "date >= CURRENT_DATE - INTERVAL '30 days' AND date < CURRENT_DATE" + case "year": + dateRangeClause = "date >= CURRENT_DATE - INTERVAL '365 days' AND date < CURRENT_DATE" + default: // all_time + dateRangeClause = "date < CURRENT_DATE" + } + + sql := fmt.Sprintf(` + SELECT + COALESCE(developer_apps.name, api_metrics_apps_unique.app_name) AS name, + api_metrics_apps_unique.app_name AS identifier, + hll_sketch, + unique_count, + total_count + FROM api_metrics_apps_unique + LEFT JOIN developer_apps ON developer_apps.address = api_metrics_apps_unique.app_name + WHERE %s + ORDER BY api_metrics_apps_unique.app_name + `, dateRangeClause) + + rows, err := app.pool.Query(c.Context(), sql) + if err != nil { + return fmt.Errorf("failed to query app unique metrics: %w", err) + } + defer rows.Close() + + type metricRow struct { + Name string `db:"name"` + Identifier string `db:"identifier"` + HllSketch []byte `db:"hll_sketch"` + UniqueCount int64 `db:"unique_count"` + TotalCount int64 `db:"total_count"` + } + + metricRows, err := pgx.CollectRows(rows, pgx.RowToStructByName[metricRow]) + if err != nil { + return fmt.Errorf("failed to collect app unique metrics: %w", err) + } + + appMap := make(map[string][]hll.SketchRow) + nameMap := make(map[string]string) // Maps identifier to display name + for _, row := range metricRows { + appMap[row.Identifier] = append(appMap[row.Identifier], hll.SketchRow{ + SketchData: row.HllSketch, + UniqueCount: row.UniqueCount, + TotalCount: row.TotalCount, + }) + // Store the display name for this identifier + nameMap[row.Identifier] = row.Name + } + + result := make([]AppUniqueMetric, 0, len(appMap)) + for identifier, sketchRows := range appMap { + merged, err := hll.MergeSketches(sketchRows, 12) + if err != nil { + return fmt.Errorf("failed to merge sketches for identifier %s: %w", identifier, err) + } + + // Use the display name from the join, or fall back to identifier + displayName := nameMap[identifier] + if displayName == "" { + displayName = identifier + } + + result = append(result, AppUniqueMetric{ + Name: displayName, + UniqueCount: int64(merged.UniqueCount), + }) + } + + sort.Slice(result, func(i, j int) bool { + return result[i].UniqueCount > result[j].UniqueCount + }) + + if len(result) > queryParams.Limit { + result = result[:queryParams.Limit] + } + + return c.JSON(fiber.Map{ + "data": result, + }) +} diff --git a/api/v1_metrics_apps_unique_test.go b/api/v1_metrics_apps_unique_test.go new file mode 100644 index 00000000..3138af55 --- /dev/null +++ b/api/v1_metrics_apps_unique_test.go @@ -0,0 +1,50 @@ +package api + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMetricsAppsUnique(t *testing.T) { + app := testAppWithFixtures(t) + + tests := []struct { + name string + timeRange string + limit int + }{ + {"all_time", "all_time", 100}, + {"month", "month", 50}, + {"week", "week", 25}, + {"year", "year", 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url := "/v1/metrics/aggregates/apps/" + tt.timeRange + "/unique" + if tt.limit != 100 { + url += fmt.Sprintf("?limit=%d", tt.limit) + } + + var response struct { + Data []struct { + Name string `json:"name"` + UniqueCount int64 `json:"unique_count"` + } `json:"data"` + } + + status, _ := testGet(t, app, url, &response) + assert.Equal(t, 200, status) + assert.NotNil(t, response.Data) + + for _, appMetric := range response.Data { + assert.NotEmpty(t, appMetric.Name, "App name should not be empty") + assert.GreaterOrEqual(t, appMetric.UniqueCount, int64(0), "Unique count should be non-negative") + } + + assert.LessOrEqual(t, len(response.Data), tt.limit, "Response should not exceed limit") + }) + } +} diff --git a/ddl/migrations/0188_api_metrics_apps_unique.sql b/ddl/migrations/0188_api_metrics_apps_unique.sql new file mode 100644 index 00000000..bbc36356 --- /dev/null +++ b/ddl/migrations/0188_api_metrics_apps_unique.sql @@ -0,0 +1,20 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS api_metrics_apps_unique ( + date DATE NOT NULL, + app_name VARCHAR(255) NOT NULL, + hll_sketch BYTEA, + total_count BIGINT NOT NULL DEFAULT 0, + unique_count BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (date, app_name) +); + +COMMENT ON TABLE api_metrics_apps_unique IS 'Stores HLL sketches for tracking unique users per application. app_name stores the identifier (api_key if present, otherwise app_name from request).'; + +CREATE INDEX IF NOT EXISTS idx_api_metrics_apps_unique_date ON api_metrics_apps_unique(date); +CREATE INDEX IF NOT EXISTS idx_api_metrics_apps_unique_app_name ON api_metrics_apps_unique(app_name); + +COMMIT; + From 5074d09fc94d9ba8c62220e9037d0d78699df187 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 20 Jan 2026 12:25:16 -0800 Subject: [PATCH 2/2] Update schema, fix test --- api/dbv1/models.go | 15 +++++- ddl/migrations/0160_artist_coin_pools.sql | 1 - sql/01_schema.sql | 60 +++++++++++++++++++---- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/api/dbv1/models.go b/api/dbv1/models.go index ac9f75cd..b41e37be 100644 --- a/api/dbv1/models.go +++ b/api/dbv1/models.go @@ -792,6 +792,17 @@ type ApiMetricsApp struct { UpdatedAt time.Time `json:"updated_at"` } +// Stores HLL sketches for tracking unique users per application. app_name stores the identifier (api_key if present, otherwise app_name from request). +type ApiMetricsAppsUnique struct { + Date pgtype.Date `json:"date"` + AppName string `json:"app_name"` + HllSketch []byte `json:"hll_sketch"` + TotalCount int64 `json:"total_count"` + UniqueCount int64 `json:"unique_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type ApiMetricsCount struct { Date pgtype.Date `json:"date"` HllSketch []byte `json:"hll_sketch"` @@ -882,13 +893,13 @@ type ArtistCoinPool struct { CreatorWalletAddress pgtype.Text `json:"creator_wallet_address"` } -// View that provides artist coin prices using DAMM V2 pool if available, DBC pools if not and still applicable, stats table if available, and artist_coin_pools.price_usd as final fallback. Makes use of the price of the quote token (AUDIO) from Birdeye if using a pool. +// View that provides artist coin prices using DAMM V2 pool if available, DBC pools if not and still applicable, artist_coin_pools.price_usd as fallback, and artist_coin_stats.price as final fallback (primarily for AUDIO and other tokens without pools). Makes use of the price of the quote token (AUDIO) from Birdeye if using a pool. type ArtistCoinPrice struct { Mint string `json:"mint"` DammV2Price pgtype.Int4 `json:"damm_v2_price"` DbcPrice pgtype.Int4 `json:"dbc_price"` - StatsPrice pgtype.Float8 `json:"stats_price"` PoolsPriceUsd pgtype.Float8 `json:"pools_price_usd"` + StatsPrice pgtype.Float8 `json:"stats_price"` Price int32 `json:"price"` } diff --git a/ddl/migrations/0160_artist_coin_pools.sql b/ddl/migrations/0160_artist_coin_pools.sql index 8595ca80..d1c0b5a3 100644 --- a/ddl/migrations/0160_artist_coin_pools.sql +++ b/ddl/migrations/0160_artist_coin_pools.sql @@ -1,4 +1,3 @@ -DROP TABLE IF EXISTS artist_coin_pools; CREATE TABLE IF NOT EXISTS artist_coin_pools ( address TEXT NOT NULL PRIMARY KEY, base_mint TEXT NOT NULL, diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 5b052d37..41f43800 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -2,9 +2,8 @@ -- PostgreSQL database dump -- - --- Dumped from database version 17.7 (Debian 17.7-3.pgdg13+1) --- Dumped by pg_dump version 17.7 (Debian 17.7-3.pgdg13+1) +-- Dumped from database version 17.5 (Debian 17.5-1.pgdg120+1) +-- Dumped by pg_dump version 17.5 (Debian 17.5-1.pgdg120+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -5862,6 +5861,28 @@ CREATE TABLE public.api_metrics_apps ( ); +-- +-- Name: api_metrics_apps_unique; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.api_metrics_apps_unique ( + date date NOT NULL, + app_name character varying(255) NOT NULL, + hll_sketch bytea, + total_count bigint DEFAULT 0 NOT NULL, + unique_count bigint DEFAULT 0 NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: TABLE api_metrics_apps_unique; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON TABLE public.api_metrics_apps_unique IS 'Stores HLL sketches for tracking unique users per application. app_name stores the identifier (api_key if present, otherwise app_name from request).'; + + -- -- Name: api_metrics_counts; Type: TABLE; Schema: public; Owner: - -- @@ -6232,21 +6253,21 @@ CREATE VIEW public.artist_coin_prices AS SELECT artist_coins.mint, damm_v2.price AS damm_v2_price, dbc.price AS dbc_price, - stats.price AS stats_price, pools.price_usd AS pools_price_usd, - COALESCE(damm_v2.price, dbc.price, stats.price, pools.price_usd) AS price + stats.price AS stats_price, + COALESCE(damm_v2.price, dbc.price, pools.price_usd, stats.price) AS price FROM ((((public.artist_coins LEFT JOIN dbc ON (((artist_coins.mint)::text = (dbc.mint)::text))) LEFT JOIN damm_v2 ON (((artist_coins.mint)::text = (damm_v2.mint)::text))) - LEFT JOIN public.artist_coin_stats stats ON ((stats.mint = (artist_coins.mint)::text))) - LEFT JOIN public.artist_coin_pools pools ON ((pools.base_mint = (artist_coins.mint)::text))); + LEFT JOIN public.artist_coin_pools pools ON ((pools.base_mint = (artist_coins.mint)::text))) + LEFT JOIN public.artist_coin_stats stats ON ((stats.mint = (artist_coins.mint)::text))); -- -- Name: VIEW artist_coin_prices; Type: COMMENT; Schema: public; Owner: - -- -COMMENT ON VIEW public.artist_coin_prices IS 'View that provides artist coin prices using DAMM V2 pool if available, DBC pools if not and still applicable, stats table if available, and artist_coin_pools.price_usd as final fallback. Makes use of the price of the quote token (AUDIO) from Birdeye if using a pool.'; +COMMENT ON VIEW public.artist_coin_prices IS 'View that provides artist coin prices using DAMM V2 pool if available, DBC pools if not and still applicable, artist_coin_pools.price_usd as fallback, and artist_coin_stats.price as final fallback (primarily for AUDIO and other tokens without pools). Makes use of the price of the quote token (AUDIO) from Birdeye if using a pool.'; -- @@ -9425,6 +9446,14 @@ ALTER TABLE ONLY public.api_metrics_apps ADD CONSTRAINT api_metrics_apps_pkey PRIMARY KEY (date, api_key, app_name); +-- +-- Name: api_metrics_apps_unique api_metrics_apps_unique_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_metrics_apps_unique + ADD CONSTRAINT api_metrics_apps_unique_pkey PRIMARY KEY (date, app_name); + + -- -- Name: api_metrics_counts api_metrics_counts_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -10731,6 +10760,20 @@ CREATE INDEX idx_api_metrics_apps_app_name ON public.api_metrics_apps USING btre CREATE INDEX idx_api_metrics_apps_date ON public.api_metrics_apps USING btree (date); +-- +-- Name: idx_api_metrics_apps_unique_app_name; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_api_metrics_apps_unique_app_name ON public.api_metrics_apps_unique USING btree (app_name); + + +-- +-- Name: idx_api_metrics_apps_unique_date; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_api_metrics_apps_unique_date ON public.api_metrics_apps_unique USING btree (date); + + -- -- Name: idx_api_metrics_counts_date; Type: INDEX; Schema: public; Owner: - -- @@ -12534,4 +12577,3 @@ ALTER TABLE ONLY public.users -- PostgreSQL database dump complete -- -