webapi_analytics.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. package state
  2. import (
  3. "context"
  4. "database/sql"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "strings"
  9. "sync"
  10. "time"
  11. )
  12. // APIUsageLog represents a single API request log entry.
  13. type APIUsageLog struct {
  14. ID int64 `json:"id"`
  15. DevID string `json:"dev_id"`
  16. Endpoint string `json:"endpoint"`
  17. Method string `json:"method"`
  18. Timestamp time.Time `json:"timestamp"`
  19. ResponseTimeMs int `json:"response_time_ms"`
  20. StatusCode int `json:"status_code"`
  21. IPAddress string `json:"ip_address"`
  22. UserAgent string `json:"user_agent"`
  23. ScreenName string `json:"screen_name,omitempty"`
  24. ErrorMessage string `json:"error_message,omitempty"`
  25. RequestSize int `json:"request_size"`
  26. ResponseSize int `json:"response_size"`
  27. }
  28. // APIUsageStats represents aggregated API usage statistics.
  29. type APIUsageStats struct {
  30. DevID string `json:"dev_id"`
  31. Endpoint string `json:"endpoint"`
  32. PeriodType string `json:"period_type"`
  33. PeriodStart time.Time `json:"period_start"`
  34. RequestCount int `json:"request_count"`
  35. ErrorCount int `json:"error_count"`
  36. TotalResponseTime int `json:"total_response_time_ms"`
  37. AvgResponseTime int `json:"avg_response_time_ms"`
  38. TotalRequestBytes int64 `json:"total_request_bytes"`
  39. TotalResponseBytes int64 `json:"total_response_bytes"`
  40. UniqueUsers int `json:"unique_users"`
  41. }
  42. // APIQuota represents API usage quotas for a developer.
  43. type APIQuota struct {
  44. DevID string `json:"dev_id"`
  45. DailyLimit int `json:"daily_limit"`
  46. MonthlyLimit int `json:"monthly_limit"`
  47. DailyUsed int `json:"daily_used"`
  48. MonthlyUsed int `json:"monthly_used"`
  49. LastResetDaily time.Time `json:"last_reset_daily"`
  50. LastResetMonthly time.Time `json:"last_reset_monthly"`
  51. OverageAllowed bool `json:"overage_allowed"`
  52. }
  53. // APIAnalytics provides analytics tracking for the Web API.
  54. type APIAnalytics struct {
  55. db *sql.DB
  56. logger *slog.Logger
  57. batchSize int
  58. buffer []APIUsageLog
  59. bufferMu sync.Mutex
  60. ticker *time.Ticker
  61. done chan bool
  62. }
  63. // NewAPIAnalytics creates a new API analytics instance.
  64. func NewAPIAnalytics(db *sql.DB, logger *slog.Logger) *APIAnalytics {
  65. analytics := &APIAnalytics{
  66. db: db,
  67. logger: logger,
  68. batchSize: 100,
  69. buffer: make([]APIUsageLog, 0, 100),
  70. ticker: time.NewTicker(5 * time.Second),
  71. done: make(chan bool),
  72. }
  73. // Start background worker for batch processing
  74. go analytics.batchProcessor()
  75. return analytics
  76. }
  77. // LogRequest logs an API request asynchronously.
  78. func (a *APIAnalytics) LogRequest(ctx context.Context, log APIUsageLog) {
  79. a.bufferMu.Lock()
  80. defer a.bufferMu.Unlock()
  81. a.buffer = append(a.buffer, log)
  82. // Flush if buffer is full
  83. if len(a.buffer) >= a.batchSize {
  84. go a.flush(context.Background())
  85. }
  86. }
  87. // LogHTTPRequest logs an HTTP request with timing information.
  88. func (a *APIAnalytics) LogHTTPRequest(ctx context.Context, r *http.Request, statusCode int, responseTime time.Duration, responseSize int, errorMsg string) {
  89. // Extract IP address
  90. ip := r.RemoteAddr
  91. if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
  92. ip = strings.Split(forwarded, ",")[0]
  93. }
  94. // Get request size
  95. requestSize := 0
  96. if r.ContentLength > 0 {
  97. requestSize = int(r.ContentLength)
  98. }
  99. // Extract dev_id from context (set by auth middleware)
  100. devID := ""
  101. if val := r.Context().Value("dev_id"); val != nil {
  102. devID = val.(string)
  103. }
  104. // Extract screen name if available
  105. screenName := ""
  106. if val := r.Context().Value("screen_name"); val != nil {
  107. screenName = val.(string)
  108. }
  109. log := APIUsageLog{
  110. DevID: devID,
  111. Endpoint: r.URL.Path,
  112. Method: r.Method,
  113. Timestamp: time.Now(),
  114. ResponseTimeMs: int(responseTime.Milliseconds()),
  115. StatusCode: statusCode,
  116. IPAddress: ip,
  117. UserAgent: r.UserAgent(),
  118. ScreenName: screenName,
  119. ErrorMessage: errorMsg,
  120. RequestSize: requestSize,
  121. ResponseSize: responseSize,
  122. }
  123. a.LogRequest(ctx, log)
  124. }
  125. // batchProcessor processes buffered logs in batches.
  126. func (a *APIAnalytics) batchProcessor() {
  127. for {
  128. select {
  129. case <-a.ticker.C:
  130. a.flush(context.Background())
  131. case <-a.done:
  132. a.flush(context.Background()) // Final flush
  133. return
  134. }
  135. }
  136. }
  137. // flush writes buffered logs to the database.
  138. func (a *APIAnalytics) flush(ctx context.Context) {
  139. a.bufferMu.Lock()
  140. if len(a.buffer) == 0 {
  141. a.bufferMu.Unlock()
  142. return
  143. }
  144. // Copy buffer and clear it
  145. logs := make([]APIUsageLog, len(a.buffer))
  146. copy(logs, a.buffer)
  147. a.buffer = a.buffer[:0]
  148. a.bufferMu.Unlock()
  149. // Insert logs in a transaction
  150. tx, err := a.db.Begin()
  151. if err != nil {
  152. a.logger.Error("failed to begin transaction for analytics", "error", err)
  153. return
  154. }
  155. defer tx.Rollback()
  156. stmt, err := tx.Prepare(`
  157. INSERT INTO api_usage_logs (
  158. dev_id, endpoint, method, timestamp, response_time_ms,
  159. status_code, ip_address, user_agent, screen_name,
  160. error_message, request_size, response_size
  161. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  162. `)
  163. if err != nil {
  164. a.logger.Error("failed to prepare analytics insert statement", "error", err)
  165. return
  166. }
  167. defer stmt.Close()
  168. for _, log := range logs {
  169. _, err := stmt.Exec(
  170. log.DevID, log.Endpoint, log.Method, log.Timestamp.Unix(),
  171. log.ResponseTimeMs, log.StatusCode, log.IPAddress, log.UserAgent,
  172. nullString(log.ScreenName), nullString(log.ErrorMessage),
  173. log.RequestSize, log.ResponseSize,
  174. )
  175. if err != nil {
  176. a.logger.Error("failed to insert analytics log", "error", err)
  177. continue
  178. }
  179. }
  180. if err := tx.Commit(); err != nil {
  181. a.logger.Error("failed to commit analytics transaction", "error", err)
  182. }
  183. }
  184. // GetUsageStats retrieves aggregated usage statistics for a developer.
  185. func (a *APIAnalytics) GetUsageStats(ctx context.Context, devID string, periodType string, startTime, endTime time.Time) ([]APIUsageStats, error) {
  186. query := `
  187. SELECT
  188. dev_id, endpoint, COUNT(*) as request_count,
  189. SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) as error_count,
  190. SUM(response_time_ms) as total_response_time,
  191. AVG(response_time_ms) as avg_response_time,
  192. SUM(request_size) as total_request_bytes,
  193. SUM(response_size) as total_response_bytes,
  194. COUNT(DISTINCT screen_name) as unique_users
  195. FROM api_usage_logs
  196. WHERE dev_id = ? AND timestamp >= ? AND timestamp <= ?
  197. GROUP BY dev_id, endpoint
  198. ORDER BY request_count DESC
  199. `
  200. rows, err := a.db.QueryContext(ctx, query, devID, startTime.Unix(), endTime.Unix())
  201. if err != nil {
  202. return nil, fmt.Errorf("failed to query usage stats: %w", err)
  203. }
  204. defer rows.Close()
  205. var stats []APIUsageStats
  206. for rows.Next() {
  207. var s APIUsageStats
  208. err := rows.Scan(
  209. &s.DevID, &s.Endpoint, &s.RequestCount,
  210. &s.ErrorCount, &s.TotalResponseTime, &s.AvgResponseTime,
  211. &s.TotalRequestBytes, &s.TotalResponseBytes, &s.UniqueUsers,
  212. )
  213. if err != nil {
  214. return nil, fmt.Errorf("failed to scan usage stats: %w", err)
  215. }
  216. s.PeriodType = periodType
  217. s.PeriodStart = startTime
  218. stats = append(stats, s)
  219. }
  220. return stats, nil
  221. }
  222. // GetTopEndpoints retrieves the most used endpoints for a developer.
  223. func (a *APIAnalytics) GetTopEndpoints(ctx context.Context, devID string, limit int) ([]struct {
  224. Endpoint string `json:"endpoint"`
  225. Count int `json:"count"`
  226. }, error) {
  227. query := `
  228. SELECT endpoint, COUNT(*) as count
  229. FROM api_usage_logs
  230. WHERE dev_id = ? AND timestamp >= ?
  231. GROUP BY endpoint
  232. ORDER BY count DESC
  233. LIMIT ?
  234. `
  235. // Look at last 24 hours
  236. since := time.Now().Add(-24 * time.Hour).Unix()
  237. rows, err := a.db.QueryContext(ctx, query, devID, since, limit)
  238. if err != nil {
  239. return nil, fmt.Errorf("failed to query top endpoints: %w", err)
  240. }
  241. defer rows.Close()
  242. var endpoints []struct {
  243. Endpoint string `json:"endpoint"`
  244. Count int `json:"count"`
  245. }
  246. for rows.Next() {
  247. var e struct {
  248. Endpoint string `json:"endpoint"`
  249. Count int `json:"count"`
  250. }
  251. if err := rows.Scan(&e.Endpoint, &e.Count); err != nil {
  252. return nil, fmt.Errorf("failed to scan endpoint: %w", err)
  253. }
  254. endpoints = append(endpoints, e)
  255. }
  256. return endpoints, nil
  257. }
  258. // CheckQuota checks if a developer has exceeded their usage quota.
  259. func (a *APIAnalytics) CheckQuota(ctx context.Context, devID string) (bool, *APIQuota, error) {
  260. // Get or create quota record
  261. quota, err := a.getOrCreateQuota(ctx, devID)
  262. if err != nil {
  263. return false, nil, err
  264. }
  265. // Check if quotas need to be reset
  266. now := time.Now()
  267. needsUpdate := false
  268. // Reset daily quota if needed
  269. if now.Sub(quota.LastResetDaily) >= 24*time.Hour {
  270. quota.DailyUsed = 0
  271. quota.LastResetDaily = now.Truncate(24 * time.Hour)
  272. needsUpdate = true
  273. }
  274. // Reset monthly quota if needed
  275. if now.Month() != quota.LastResetMonthly.Month() || now.Year() != quota.LastResetMonthly.Year() {
  276. quota.MonthlyUsed = 0
  277. quota.LastResetMonthly = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
  278. needsUpdate = true
  279. }
  280. // Update quota if needed
  281. if needsUpdate {
  282. if err := a.updateQuota(ctx, quota); err != nil {
  283. return false, nil, err
  284. }
  285. }
  286. // Check if within limits
  287. withinLimits := (quota.DailyUsed < quota.DailyLimit && quota.MonthlyUsed < quota.MonthlyLimit) || quota.OverageAllowed
  288. return withinLimits, quota, nil
  289. }
  290. // IncrementQuotaUsage increments the usage counters for a developer.
  291. func (a *APIAnalytics) IncrementQuotaUsage(ctx context.Context, devID string) error {
  292. query := `
  293. UPDATE api_quotas
  294. SET daily_used = daily_used + 1,
  295. monthly_used = monthly_used + 1
  296. WHERE dev_id = ?
  297. `
  298. _, err := a.db.ExecContext(ctx, query, devID)
  299. return err
  300. }
  301. // getOrCreateQuota retrieves or creates a quota record for a developer.
  302. func (a *APIAnalytics) getOrCreateQuota(ctx context.Context, devID string) (*APIQuota, error) {
  303. quota := &APIQuota{DevID: devID}
  304. query := `
  305. SELECT daily_limit, monthly_limit, daily_used, monthly_used,
  306. last_reset_daily, last_reset_monthly, overage_allowed
  307. FROM api_quotas
  308. WHERE dev_id = ?
  309. `
  310. err := a.db.QueryRowContext(ctx, query, devID).Scan(
  311. &quota.DailyLimit, &quota.MonthlyLimit,
  312. &quota.DailyUsed, &quota.MonthlyUsed,
  313. &quota.LastResetDaily, &quota.LastResetMonthly,
  314. &quota.OverageAllowed,
  315. )
  316. if err == sql.ErrNoRows {
  317. // Create default quota
  318. now := time.Now()
  319. quota = &APIQuota{
  320. DevID: devID,
  321. DailyLimit: 10000,
  322. MonthlyLimit: 300000,
  323. DailyUsed: 0,
  324. MonthlyUsed: 0,
  325. LastResetDaily: now.Truncate(24 * time.Hour),
  326. LastResetMonthly: time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()),
  327. OverageAllowed: false,
  328. }
  329. insertQuery := `
  330. INSERT INTO api_quotas (
  331. dev_id, daily_limit, monthly_limit, daily_used, monthly_used,
  332. last_reset_daily, last_reset_monthly, overage_allowed
  333. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  334. `
  335. _, err = a.db.ExecContext(ctx, insertQuery,
  336. quota.DevID, quota.DailyLimit, quota.MonthlyLimit,
  337. quota.DailyUsed, quota.MonthlyUsed,
  338. quota.LastResetDaily.Unix(), quota.LastResetMonthly.Unix(),
  339. quota.OverageAllowed,
  340. )
  341. if err != nil {
  342. return nil, fmt.Errorf("failed to create quota: %w", err)
  343. }
  344. } else if err != nil {
  345. return nil, fmt.Errorf("failed to get quota: %w", err)
  346. }
  347. return quota, nil
  348. }
  349. // updateQuota updates a quota record.
  350. func (a *APIAnalytics) updateQuota(ctx context.Context, quota *APIQuota) error {
  351. query := `
  352. UPDATE api_quotas
  353. SET daily_used = ?, monthly_used = ?,
  354. last_reset_daily = ?, last_reset_monthly = ?
  355. WHERE dev_id = ?
  356. `
  357. _, err := a.db.ExecContext(ctx, query,
  358. quota.DailyUsed, quota.MonthlyUsed,
  359. quota.LastResetDaily.Unix(), quota.LastResetMonthly.Unix(),
  360. quota.DevID,
  361. )
  362. return err
  363. }
  364. // Close stops the analytics processor.
  365. func (a *APIAnalytics) Close() {
  366. close(a.done)
  367. a.ticker.Stop()
  368. }
  369. // nullString returns a sql.NullString for the given string.
  370. func nullString(s string) sql.NullString {
  371. if s == "" {
  372. return sql.NullString{Valid: false}
  373. }
  374. return sql.NullString{String: s, Valid: true}
  375. }