Skip to content

Commit 73aeedf

Browse files
Merge pull request #4895 from linuxfoundation/unicron-add-api-logs-in-dynamodb-prod
Unicron add api logs in dynamodb prod
2 parents 15d34aa + 033e07b commit 73aeedf

14 files changed

Lines changed: 632 additions & 12 deletions

cla-backend-go/api_logs/models.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright The Linux Foundation and each contributor to CommunityBridge.
2+
// SPDX-License-Identifier: MIT
3+
4+
package api_logs
5+
6+
import (
7+
"fmt"
8+
"time"
9+
)
10+
11+
// APILog data model for DynamoDB table cla-{stage}-api-log
12+
type APILog struct {
13+
URL string `dynamodbav:"url" json:"url"`
14+
DT int64 `dynamodbav:"dt" json:"dt"`
15+
Bucket string `dynamodbav:"bucket" json:"bucket"`
16+
}
17+
18+
// String returns a string representation of the APILog
19+
func (a *APILog) String() string {
20+
return fmt.Sprintf("APILog{URL: %s, DT: %d, Bucket: %s}", a.URL, a.DT, a.Bucket)
21+
}
22+
23+
// NewAPILog creates a new APILog entry with current timestamp
24+
func NewAPILog(url, bucket string) *APILog {
25+
return &APILog{
26+
URL: url,
27+
DT: time.Now().UnixMilli(), // Unix timestamp in milliseconds
28+
Bucket: bucket,
29+
}
30+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright The Linux Foundation and each contributor to CommunityBridge.
2+
// SPDX-License-Identifier: MIT
3+
4+
package api_logs
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"strings"
10+
"time"
11+
12+
"github.com/aws/aws-sdk-go/aws"
13+
"github.com/aws/aws-sdk-go/service/dynamodb"
14+
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
15+
)
16+
17+
const (
18+
// APILogTableName is the DynamoDB table name for API logs
19+
APILogTableName = "cla-%s-api-log"
20+
)
21+
22+
// Repository interface for API logs
23+
type Repository interface {
24+
LogAPIRequest(ctx context.Context, url string) error
25+
}
26+
27+
// repository implements the Repository interface
28+
type repository struct {
29+
stage string
30+
dynamoDBClient *dynamodb.DynamoDB
31+
}
32+
33+
// NewRepository creates a new API logs repository
34+
func NewRepository(stage string, dynamoDBClient *dynamodb.DynamoDB) Repository {
35+
return &repository{
36+
stage: stage,
37+
dynamoDBClient: dynamoDBClient,
38+
}
39+
}
40+
41+
// LogAPIRequest logs an API request to the DynamoDB table
42+
// Creates three entries: ALL bucket, daily bucket (YYYY-MM-DD), and monthly bucket (YYYY-MM)
43+
// IMPORTANT: table key is (url, dt). To avoid overwrites, dt is shifted by -1/0/+1 ms per bucket.
44+
func (r *repository) LogAPIRequest(ctx context.Context, url string) error {
45+
// 200% fail-safe: never panic on nil ctx/client
46+
if ctx == nil {
47+
ctx = context.Background()
48+
}
49+
if r == nil || r.dynamoDBClient == nil {
50+
return fmt.Errorf("dynamodb client is nil")
51+
}
52+
53+
now := time.Now().UTC()
54+
timestamp := now.UnixMilli()
55+
56+
// Generate bucket names
57+
dailyBucket := now.Format("2006-01-02") // YYYY-MM-DD
58+
monthlyBucket := now.Format("2006-01") // YYYY-MM
59+
60+
entries := []*APILog{
61+
{URL: url, DT: timestamp - 1, Bucket: "ALL"},
62+
{URL: url, DT: timestamp, Bucket: dailyBucket},
63+
{URL: url, DT: timestamp + 1, Bucket: monthlyBucket},
64+
}
65+
tableName := fmt.Sprintf(APILogTableName, r.stage)
66+
67+
var errs []string
68+
for _, logEntry := range entries {
69+
// Convert to DynamoDB attribute value
70+
av, err := dynamodbattribute.MarshalMap(logEntry)
71+
if err != nil {
72+
errs = append(errs, fmt.Sprintf("bucket=%s marshal=%v", logEntry.Bucket, err))
73+
continue
74+
}
75+
76+
// Put item to DynamoDB
77+
input := &dynamodb.PutItemInput{
78+
TableName: aws.String(tableName),
79+
Item: av,
80+
}
81+
82+
_, err = r.dynamoDBClient.PutItemWithContext(ctx, input)
83+
if err != nil {
84+
errs = append(errs, fmt.Sprintf("bucket=%s put=%v", logEntry.Bucket, err))
85+
continue
86+
}
87+
}
88+
89+
// Return error so middleware can emit a single LG:* line.
90+
if len(errs) > 0 {
91+
return fmt.Errorf("%s", strings.Join(errs, "; "))
92+
}
93+
return nil
94+
}

cla-backend-go/cmd/server.go

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"runtime"
1616
"strconv"
1717
"strings"
18+
"time"
1819

1920
"github.com/linuxfoundation/easycla/cla-backend-go/project/repository"
2021
"github.com/linuxfoundation/easycla/cla-backend-go/project/service"
@@ -78,6 +79,7 @@ import (
7879

7980
"github.com/linuxfoundation/easycla/cla-backend-go/users"
8081

82+
"github.com/linuxfoundation/easycla/cla-backend-go/api_logs"
8183
"github.com/linuxfoundation/easycla/cla-backend-go/signatures"
8284
v2Signatures "github.com/linuxfoundation/easycla/cla-backend-go/v2/signatures"
8385

@@ -144,12 +146,34 @@ type combinedRepo struct {
144146
projects_cla_groups.Repository
145147
}
146148

147-
// in cmd/server.go (top-level imports already use logrus)
148-
func apiPathLogger(next http.Handler) http.Handler {
149-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
150-
log.Infof("LG:api-request-path:%s", r.URL.Path)
151-
next.ServeHTTP(w, r)
152-
})
149+
// apiPathLoggerWithDB creates a middleware that logs API requests to DynamoDB
150+
func apiPathLoggerWithDB(apiLogsRepo api_logs.Repository) func(http.Handler) http.Handler {
151+
return func(next http.Handler) http.Handler {
152+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
153+
log.Infof("LG:api-request-path:%s", r.URL.Path)
154+
155+
// Log to DynamoDB table (fire-and-forget, never fail request)
156+
if apiLogsRepo != nil {
157+
path := r.URL.Path
158+
go func() {
159+
defer func() {
160+
if rec := recover(); rec != nil {
161+
log.Infof("LG:api-log-dynamo-failed:%s err=panic:%v", path, rec)
162+
}
163+
}()
164+
165+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
166+
defer cancel()
167+
168+
if err := apiLogsRepo.LogAPIRequest(ctx, path); err != nil {
169+
log.Infof("LG:api-log-dynamo-failed:%s err=%v", path, err)
170+
}
171+
}()
172+
}
173+
174+
next.ServeHTTP(w, r)
175+
})
176+
}
153177
}
154178

155179
// server function called by environment specific server functions
@@ -281,6 +305,7 @@ func server(localMode bool) http.Handler {
281305
approvalListRepo := approval_list.NewRepository(awsSession, stage)
282306
v1CompanyRepo := v1Company.NewRepository(awsSession, stage)
283307
eventsRepo := events.NewRepository(awsSession, stage)
308+
apiLogsRepo := api_logs.NewRepository(stage, dynamodb.New(awsSession))
284309
v1ProjectClaGroupRepo := projects_cla_groups.NewRepository(awsSession, stage)
285310
v1CLAGroupRepo := repository.NewRepository(awsSession, stage, gitV1Repository, gerritRepo, v1ProjectClaGroupRepo)
286311
metricsRepo := metrics.NewRepository(awsSession, stage, configFile.APIGatewayURL, v1ProjectClaGroupRepo)
@@ -406,7 +431,7 @@ func server(localMode bool) http.Handler {
406431
// The middleware configuration is for the handler executors. These do not apply to the swagger.json document.
407432
// The middleware executes after routing but before authentication, binding and validation
408433
middlewareSetupfunc := func(handler http.Handler) http.Handler {
409-
return apiPathLogger(setRequestIDHandler(responseLoggingMiddleware(userCreaterMiddleware(handler))))
434+
return apiPathLoggerWithDB(apiLogsRepo)(setRequestIDHandler(responseLoggingMiddleware(userCreaterMiddleware(handler))))
410435
}
411436

412437
v2API.CsvProducer = openapi_runtime.ProducerFunc(func(w io.Writer, data interface{}) error {

cla-backend-go/serverless.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ provider:
122122
- dynamodb:DescribeStream
123123
- dynamodb:ListStreams
124124
Resource:
125+
- "arn:aws:dynamodb:${self:custom.dynamodb.region}:${aws:accountId}:table/cla-${opt:stage}-api-log"
125126
- "arn:aws:dynamodb:${self:custom.dynamodb.region}:${aws:accountId}:table/cla-${opt:stage}-ccla-whitelist-requests"
126127
- "arn:aws:dynamodb:${self:custom.dynamodb.region}:${aws:accountId}:table/cla-${opt:stage}-cla-manager-requests"
127128
- "arn:aws:dynamodb:${self:custom.dynamodb.region}:${aws:accountId}:table/cla-${opt:stage}-companies"
@@ -144,6 +145,7 @@ provider:
144145
Action:
145146
- dynamodb:Query
146147
Resource:
148+
- "arn:aws:dynamodb:${self:custom.dynamodb.region}:${aws:accountId}:table/cla-${opt:stage}-api-log/index/bucket-dt-index"
147149
- "arn:aws:dynamodb:${self:custom.dynamodb.region}:${aws:accountId}:table/cla-${opt:stage}-ccla-whitelist-requests/index/company-id-project-id-index"
148150
- "arn:aws:dynamodb:${self:custom.dynamodb.region}:${aws:accountId}:table/cla-${opt:stage}-ccla-whitelist-requests/index/ccla-approval-list-request-project-id-index"
149151
- "arn:aws:dynamodb:${self:custom.dynamodb.region}:${aws:accountId}:table/cla-${opt:stage}-users/index/github-id-index"

cla-backend/cla/models/dynamo_models.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
53875519
class CCLAAllowlistRequestModel(BaseModel):
53885520
"""
53895521
Represents a CCLAAllowlistRequest in the database

0 commit comments

Comments
 (0)