Coverage for app/backend/src/couchers/models/logging.py: 100%
94 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1import enum
2from datetime import datetime
3from typing import Any
5from sqlalchemy import BigInteger, Boolean, DateTime, Enum, Float, Index, String, UniqueConstraint, func
6from sqlalchemy import LargeBinary as Binary
7from sqlalchemy.dialects.postgresql import JSONB
8from sqlalchemy.orm import Mapped, mapped_column
9from sqlalchemy.sql import expression
11from couchers.config import config
12from couchers.models.base import Base
13from couchers.models.rest import ClientPlatform
16class EventSource(enum.Enum):
17 backend = enum.auto()
18 frontend = enum.auto()
21class ExposureSource(enum.Enum):
22 backend = enum.auto()
23 client = enum.auto()
26class APICall(Base, kw_only=True):
27 """
28 API call logs
29 """
31 __tablename__ = "api_calls"
32 __table_args__ = {"schema": "logging"}
34 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
36 # whether the call was made using an api key or session cookies
37 is_api_key: Mapped[bool] = mapped_column(Boolean, server_default=expression.false())
39 # backend version (normally e.g. develop-31469e3), allows us to figure out which proto definitions were used
40 # note that `default` is a python side default, not hardcoded into DB schema
41 version: Mapped[str] = mapped_column(String, default=config.VERSION)
43 # approximate time of the call
44 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
46 # the method call name, e.g. "/org.couchers.api.core.API/ListFriends"
47 method: Mapped[str] = mapped_column(String)
49 # gRPC status code name, e.g. FAILED_PRECONDITION, None if success
50 status_code: Mapped[str | None] = mapped_column(String, default=None)
52 # handler duration (excluding serialization, etc)
53 duration: Mapped[float] = mapped_column(Float)
55 # user_id of caller, None means not logged in
56 user_id: Mapped[int | None] = mapped_column(BigInteger, default=None)
58 # sanitized request bytes
59 request: Mapped[bytes | None] = mapped_column(Binary, default=None)
61 # sanitized response bytes
62 response: Mapped[bytes | None] = mapped_column(Binary, default=None)
64 # whether response bytes have been truncated
65 response_truncated: Mapped[bool] = mapped_column(Boolean, server_default=expression.false())
67 # the exception traceback, if any
68 traceback: Mapped[str | None] = mapped_column(String, default=None)
70 # human readable perf report
71 perf_report: Mapped[str | None] = mapped_column(String, default=None)
73 # per-request resource accounting, covering the handler span (see couchers/perf.py)
74 db_query_count: Mapped[int | None] = mapped_column(BigInteger, default=None)
75 # counts only SQLAlchemy rendered insert/update/delete
76 db_write_query_count: Mapped[int | None] = mapped_column(BigInteger, default=None)
77 db_time_ms: Mapped[float | None] = mapped_column(Float, default=None)
78 cpu_ms: Mapped[float | None] = mapped_column(Float, default=None)
80 client_platform: Mapped[ClientPlatform | None] = mapped_column(Enum(ClientPlatform), default=None)
82 # details of the browser, if available
83 ip_address: Mapped[str | None] = mapped_column(String, default=None)
84 user_agent: Mapped[str | None] = mapped_column(String, default=None)
86 sofa: Mapped[str | None] = mapped_column(String, default=None)
89class EventLog(Base, kw_only=True):
90 """
91 Analytics event log for tracking user behavior and business metrics.
93 Append-only table for ELT extraction. Do not query this table for user-facing features.
94 """
96 __tablename__ = "event_log"
98 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
100 # when the row was inserted into the DB
101 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
103 # when the event actually happened (same as created for backend; may differ for frontend events)
104 occurred: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
106 # backend/frontend version
107 version: Mapped[str] = mapped_column(String, default=config.VERSION)
109 # sofa, null for background/system events
110 sofa: Mapped[str | None] = mapped_column(String, default=None)
112 # hierarchical event type, e.g. "host_request.sent", "account.login"
113 event_type: Mapped[str] = mapped_column(String)
115 # user who triggered the event, nullable for system events
116 user_id: Mapped[int | None] = mapped_column(BigInteger, default=None)
118 # flexible event-specific properties
119 properties: Mapped[dict[str, Any]] = mapped_column(JSONB)
121 # numeric value (duration, count, etc.)
122 value: Mapped[float] = mapped_column(Float, server_default="1.0", default=1.0)
124 # where the event originated
125 source: Mapped[EventSource] = mapped_column(Enum(EventSource))
127 __table_args__ = (
128 Index("ix_logging_event_log_created", "created"),
129 Index("ix_logging_event_log_event_type_created", "event_type", "created"),
130 Index("ix_logging_event_log_user_id_created", "user_id", "created"),
131 {"schema": "logging"},
132 )
135class ExperimentExposure(Base, kw_only=True):
136 """
137 Records the first time a user is exposed to a particular experiment variation.
139 Populated by GrowthBook's on_experiment_viewed callback. One row per
140 (user, experiment, variation) - subsequent exposures collide on the
141 unique constraint and are dropped via ON CONFLICT DO NOTHING, so
142 `created` and `data` reflect the first exposure.
143 """
145 __tablename__ = "experiment_exposures"
147 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
149 # when the first exposure was recorded
150 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
152 # backend version when the first exposure was recorded
153 version: Mapped[str] = mapped_column(String, default=config.VERSION)
155 # user exposed to the experiment
156 user_id: Mapped[int] = mapped_column(BigInteger)
158 # experiment identifier from GrowthBook
159 experiment_key: Mapped[str] = mapped_column(String)
161 # the variation the user was bucketed into
162 variation_id: Mapped[int] = mapped_column(BigInteger)
164 source: Mapped[ExposureSource] = mapped_column(Enum(ExposureSource))
166 # remaining GrowthBook fields (variation_key, hash_attribute, hash_value,
167 # bucket, in_experiment, feature_id, sticky_bucket_used, etc.)
168 data: Mapped[dict[str, Any]] = mapped_column(JSONB)
170 __table_args__ = (
171 UniqueConstraint("user_id", "experiment_key", "variation_id", name="uq_experiment_exposures_user_exp_var"),
172 Index("ix_logging_experiment_exposures_experiment_key_created", "experiment_key", "created"),
173 Index("ix_logging_experiment_exposures_user_id_created", "user_id", "created"),
174 {"schema": "logging"},
175 )
178class FeatureUsage(Base, kw_only=True):
179 """
180 Append-only log of feature flag evaluations.
182 Populated by GrowthBook's on_feature_usage callback - one row per check.
183 """
185 __tablename__ = "feature_usage"
187 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
189 # when the feature was checked
190 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
192 # user the feature was checked for
193 user_id: Mapped[int] = mapped_column(BigInteger)
195 # feature identifier from GrowthBook
196 feature_key: Mapped[str] = mapped_column(String)
198 # the feature value the user received
199 value: Mapped[Any] = mapped_column(JSONB)
201 __table_args__ = (
202 Index("ix_logging_feature_usage_feature_key_time", "feature_key", "time"),
203 Index("ix_logging_feature_usage_user_id_time", "user_id", "time"),
204 {"schema": "logging"},
205 )
208class NonvisibleUserAccessType(enum.Enum):
209 login_attempt = enum.auto()
210 profile_view = enum.auto()
211 ghost_served = enum.auto()
214class NonvisibleUserState(enum.Enum):
215 banned = enum.auto()
216 shadowed = enum.auto()
217 deleted = enum.auto()
220class NonvisibleUserAccess(Base, kw_only=True):
221 __tablename__ = "nonvisible_user_access"
223 id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
225 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
227 version: Mapped[str] = mapped_column(String, default=config.VERSION)
229 access_type: Mapped[NonvisibleUserAccessType] = mapped_column(Enum(NonvisibleUserAccessType))
231 target_user_id: Mapped[int] = mapped_column(BigInteger)
232 target_state: Mapped[NonvisibleUserState] = mapped_column(Enum(NonvisibleUserState))
234 actor_user_id: Mapped[int | None] = mapped_column(BigInteger, default=None)
236 ip_address: Mapped[str | None] = mapped_column(String, default=None)
237 user_agent: Mapped[str | None] = mapped_column(String, default=None)
238 sofa: Mapped[str | None] = mapped_column(String, default=None)
240 __table_args__ = (
241 Index("ix_logging_nonvisible_user_access_target_user_id_time", "target_user_id", "time"),
242 Index("ix_logging_nonvisible_user_access_actor_user_id_time", "actor_user_id", "time"),
243 Index("ix_logging_nonvisible_user_access_sofa", "sofa"),
244 Index("ix_logging_nonvisible_user_access_ip_address", "ip_address"),
245 {"schema": "logging"},
246 )