@@ -58,6 +58,7 @@ def create_database():
5858 GerritModel ,
5959 EventModel ,
6060 CCLAAllowlistRequestModel ,
61+ APILogModel ,
6162
6263 ]
6364 # Create all required tables.
@@ -83,6 +84,7 @@ def delete_database():
8384 GitHubOrgModel ,
8485 GerritModel ,
8586 CCLAAllowlistRequestModel ,
87+ APILogModel ,
8688 ]
8789 # Delete all existing tables.
8890 for table in tables :
@@ -5384,6 +5386,136 @@ def create_event(
53845386 return {"errors" : {"event_id" : str (err )}}
53855387
53865388
5389+ class APILogBucketDTIndex (GlobalSecondaryIndex ):
5390+ """
5391+ This class represents a global secondary index for querying API logs by bucket and time range.
5392+ """
5393+
5394+ class Meta :
5395+ """Meta class for API Log bucket-dt index."""
5396+
5397+ index_name = "bucket-dt-index"
5398+ write_capacity_units = int (cla .conf .get ("DYNAMO_WRITE_UNITS" , 10 ))
5399+ read_capacity_units = int (cla .conf .get ("DYNAMO_READ_UNITS" , 10 ))
5400+ # All attributes are projected - not sure if this is necessary.
5401+ projection = AllProjection ()
5402+
5403+ # This attribute is the hash key for the index.
5404+ bucket = UnicodeAttribute (hash_key = True )
5405+ # This attribute is the range key for the index.
5406+ dt = NumberAttribute (range_key = True )
5407+
5408+
5409+ class APILogModel (BaseModel ):
5410+ """
5411+ Represents an API log entry in the database
5412+ """
5413+
5414+ class Meta :
5415+ """Meta class for APILog."""
5416+
5417+ table_name = "cla-{}-api-log" .format (stage )
5418+ if stage == "local" :
5419+ host = "http://localhost:8000"
5420+
5421+ url = UnicodeAttribute (hash_key = True )
5422+ dt = NumberAttribute (range_key = True )
5423+ bucket = UnicodeAttribute (null = False )
5424+
5425+ # GSI for querying by bucket and time range
5426+ bucket_dt_index = APILogBucketDTIndex ()
5427+
5428+
5429+ class APILog (model_interfaces .APILog ):
5430+ """
5431+ ORM-agnostic wrapper for the DynamoDB APILog model.
5432+ """
5433+
5434+ def __init__ (self , url = None , dt = None , bucket = None ):
5435+ super ().__init__ ()
5436+ self .model = APILogModel ()
5437+ self .model .url = url
5438+ self .model .dt = dt
5439+ self .model .bucket = bucket
5440+
5441+ def __str__ (self ):
5442+ return f"url:{ self .model .url } , dt:{ self .model .dt } , bucket:{ self .model .bucket } "
5443+
5444+ def to_dict (self ):
5445+ return dict (self .model )
5446+
5447+ def save (self ) -> None :
5448+ # self.model.date_modified = datetime.datetime.utcnow()
5449+ self .model .save ()
5450+
5451+ def load (self , url , dt ):
5452+ try :
5453+ api_log = self .model .get (str (url ), int (dt ))
5454+ except APILogModel .DoesNotExist :
5455+ raise cla .models .DoesNotExist ("API Log entry not found" )
5456+ self .model = api_log
5457+
5458+ def delete (self ):
5459+ self .model .delete ()
5460+
5461+ def get_url (self ):
5462+ return self .model .url
5463+
5464+ def get_dt (self ):
5465+ return self .model .dt
5466+
5467+ def get_bucket (self ):
5468+ return self .model .bucket
5469+
5470+ def set_url (self , url ):
5471+ self .model .url = url
5472+
5473+ def set_dt (self , dt ):
5474+ self .model .dt = dt
5475+
5476+ def set_bucket (self , bucket ):
5477+ self .model .bucket = bucket
5478+
5479+ @classmethod
5480+ def log_api_request (cls , url : str ):
5481+ """
5482+ Log an API request with the given URL.
5483+ Creates three entries: ALL bucket, daily bucket, and monthly bucket.
5484+ Never raises exceptions - logs errors instead.
5485+ """
5486+ try :
5487+ # Base timestamp in milliseconds
5488+ base_dt = int (time .time () * 1000 )
5489+ dt_obj = datetime .datetime .utcnow ()
5490+
5491+ # Buckets
5492+ daily_bucket = dt_obj .strftime ('%Y-%m-%d' )
5493+ monthly_bucket = dt_obj .strftime ('%Y-%m' )
5494+
5495+ # IMPORTANT: table key is (url, dt). To avoid overwrites we shift dt by -1/0/+1 ms.
5496+ entries = [
5497+ ("ALL" , base_dt - 1 ),
5498+ (daily_bucket , base_dt ),
5499+ (monthly_bucket , base_dt + 1 ),
5500+ ]
5501+
5502+ errors = []
5503+ for bucket , dt_value in entries :
5504+ try :
5505+ api_log = cls (url = url , dt = dt_value , bucket = bucket )
5506+ api_log .save ()
5507+ except Exception as e :
5508+ errors .append (f"bucket={ bucket } err={ e } " )
5509+
5510+ if errors :
5511+ # Only AWS logs entry (LG-style), never fail request flow
5512+ cla .log .info (f"LG:api-log-dynamo-failed:{ url } " + "; " .join (errors ))
5513+
5514+ except Exception as e :
5515+ # Never let API logging failure break the request flow
5516+ cla .log .info (f"LG:api-log-dynamo-failed:{ url } err={ e } " )
5517+
5518+
53875519class CCLAAllowlistRequestModel (BaseModel ):
53885520 """
53895521 Represents a CCLAAllowlistRequest in the database
0 commit comments