feat: add last active time per user

This commit is contained in:
Sam 2023-05-02 02:54:08 +02:00
parent 90c7dcf891
commit cf95e69349
No known key found for this signature in database
GPG Key ID: B4EF20DDE721CAA1
8 changed files with 85 additions and 234 deletions

View File

@ -6,8 +6,10 @@ import (
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/u1f320/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/jackc/pgx/v5/pgconn"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/rs/xid"
) )
func (db *DB) initMetrics() (err error) { func (db *DB) initMetrics() (err error) {
@ -39,6 +41,20 @@ func (db *DB) initMetrics() (err error) {
return errors.Wrap(err, "registering member count gauge") return errors.Wrap(err, "registering member count gauge")
} }
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_users_active",
Help: "The number of users active in the past 30 days",
}, func() float64 {
count, err := db.ActiveUsers(context.Background())
if err != nil {
log.Errorf("getting active user count for metrics: %v", err)
}
return float64(count)
}))
if err != nil {
return errors.Wrap(err, "registering active user count gauge")
}
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_database_latency", Name: "pronouns_database_latency",
Help: "The latency to the database in nanoseconds", Help: "The latency to the database in nanoseconds",
@ -51,6 +67,9 @@ func (db *DB) initMetrics() (err error) {
} }
return float64(time.Since(start)) return float64(time.Since(start))
})) }))
if err != nil {
return errors.Wrap(err, "registering database latency gauge")
}
db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{ db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{
Name: "pronouns_api_requests_total", Name: "pronouns_api_requests_total",
@ -75,3 +94,32 @@ func (db *DB) TotalMemberCount(ctx context.Context) (numMembers int64, err error
} }
return numMembers, nil return numMembers, nil
} }
const activeTime = 30 * 24 * time.Hour
func (db *DB) ActiveUsers(ctx context.Context) (numUsers int64, err error) {
t := time.Now().Add(-activeTime)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL AND last_active > $1", t).Scan(&numUsers)
if err != nil {
return 0, errors.Wrap(err, "querying active user count")
}
return numUsers, nil
}
type connOrTx interface {
Exec(ctx context.Context, sql string, arguments ...any) (commandTag pgconn.CommandTag, err error)
}
// UpdateActiveTime is called on create and update endpoints (PATCH /users/@me, POST/PATCH/DELETE /members)
func (db *DB) UpdateActiveTime(ctx context.Context, tx connOrTx, userID xid.ID) (err error) {
sql, args, err := sq.Update("users").Set("last_active", time.Now().UTC()).Where("id = ?", userID).ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = tx.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "executing query")
}
return nil
}

View File

@ -24,6 +24,7 @@ type User struct {
DisplayName *string DisplayName *string
Bio *string Bio *string
MemberTitle *string MemberTitle *string
LastActive time.Time
Avatar *string Avatar *string
Links []string Links []string

View File

@ -176,6 +176,13 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
} }
} }
// update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err
}
err = tx.Commit(ctx) err = tx.Commit(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "committing transaction") return errors.Wrap(err, "committing transaction")

View File

@ -9,6 +9,7 @@ import (
"github.com/rs/xid" "github.com/rs/xid"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/u1f320/pronouns.cc/backend/server"
) )
@ -51,6 +52,13 @@ func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
} }
} }
// update last active time
err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err
}
render.NoContent(w, r) render.NoContent(w, r)
return nil return nil
} }

View File

@ -270,6 +270,13 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
} }
} }
// update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err
}
err = tx.Commit(ctx) err = tx.Commit(ctx)
if err != nil { if err != nil {
log.Errorf("committing transaction: %v", err) log.Errorf("committing transaction: %v", err)

View File

@ -252,6 +252,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
} }
} }
// update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err
}
err = tx.Commit(ctx) err = tx.Commit(ctx)
if err != nil { if err != nil {
log.Errorf("committing transaction: %v", err) log.Errorf("committing transaction: %v", err)

View File

@ -1,234 +0,0 @@
openapi: 3.1.0
info:
title: pronouns.cc API
version: 1.0.0
servers:
- url: https://pronouns.cc/api/v1
paths:
/users/{userRef}:
parameters:
- name: userRef
in: path
required: true
schema:
anyOf:
- $ref: "#/components/schemas/xid"
- $ref: "#/components/schemas/name"
get:
summary: /users/{userRef}
description: Get a user's information.
tags: [Users]
operationId: GetUser
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/User"
"404":
description: No user with that name or ID found.
content:
application/json:
schema:
$ref: "#/components/schemas/APIError"
components:
schemas:
XID:
title: ID
type: string
readOnly: true
minLength: 20
maxLength: 20
pattern: "^[0-9a-v]{20}$"
example: "ce6v1aje6i88cb6k5heg"
description: A unique, unchanging identifier for a user or a member.
Name:
title: Name
type: string
readOnly: false
minLength: 2
maxLength: 40
pattern: "^[\\w-.]{2,40}$"
example: "testingUser"
description: A user-defined identifier for a user or a member.
WordStatus:
type: integer
oneOf:
- title: Favourite
const: 1
description: Name/pronouns is user's/member's favourite
- title: Okay
const: 2
description: Name/pronouns is okay to use
- title: Jokingly
const: 3
description: Name/pronouns should only be used jokingly
- title: Friends only
const: 4
description: Name/pronouns can only be used by friends
- title: Avoid
const: 5
description: Name/pronouns should be avoided
example: 2
description: Status for name/pronouns.
Names:
type: array
items:
type: object
properties:
name:
type: string
required: true
minLength: 1
maxLength: 50
summary: A single name entry.
example: "Testington"
status:
$ref: "#/components/schemas/WordStatus"
description: Array of user's/member's name preferences.
Pronouns:
type: array
items:
type: object
properties:
pronouns:
type: string
required: true
minLength: 1
maxLength: 50
summary: A single pronouns entry.
example: "it/it/its/its/itself"
display_text:
type: string
required: false
nullable: true
minLength: 1
maxLenght: 50
summary: A pronoun's display text. If not present, the first two forms (separated by /) in `pronouns` is used.
example: "it/its"
status:
$ref: "#/components/schemas/WordStatus"
description: Array of user's/member's pronoun preferences.
Field:
type: object
properties:
name:
type: string
nullable: false
required: true
minLength: 1
maxLength: 100
example: "Name"
description: The field's name.
favourite:
type: array
items:
type: string
description: The field's favourite entries.
okay:
type: array
items:
type: string
description: The field's okay entries.
jokingly:
type: array
items:
type: string
description: The field's joking entries.
friends_only:
type: array
items:
type: string
description: The field's friends only entries.
avoid:
type: array
items:
type: string
description: The field's avoid entries.
User:
type: object
properties:
id:
$ref: "#/components/schemas/XID"
name:
$ref: "#/components/schemas/Name"
display_name:
type: string
nullable: true
readOnly: false
minLength: 1
maxLength: 100
example: "Testington, Head Tester"
description: An optional nickname.
bio:
type: string
nullable: true
readOnly: false
minLength: 1
maxLength: 1000
example: "Hi! I'm a user!"
description: An optional bio/description.
avatar_urls:
type: array
nullable: true
items:
type: string
readOnly: true
example: ["https://pronouns.cc/avatars/members/ce6v1aje6i88cb6k5heg.webp", "https://pronouns.cc/avatars/members/ce6v1aje6i88cb6k5heg.jpg"]
description: |
An optional array of avatar URLs.
The first entry is the canonical avatar URL (the one that should be used if possible),
if the array has more entries, those are alternative formats.
links:
type: array
nullable: true
items:
type: string
minLength: 1
maxLength: 256
readOnly: false
example: ["https://pronouns.cc", "https://codeberg.org/u1f320"]
description: An optional array of links associated with the user.
names:
$ref: "#/components/schemas/Names"
pronouns:
$ref: "#/components/schemas/Pronouns"
fields:
type: array
nullable: true
items:
$ref: "#/components/schemas/Field"
APIError:
type: object
properties:
code:
type: integer
optional: false
nullable: false
readOnly: true
description: A machine-readable error code.
message:
type: string
optional: false
nullable: false
readOnly: true
description: A human-readable error string.
details:
type: string
optional: true
nullable: false
readOnly: true
description: Human-readable details, if applicable.
ratelimit_reset:
type: integer
optional: true
nullable: false
readOnly: true
description: Unix timestamp after which you can make requests again, if this is a rate limit error.

View File

@ -0,0 +1,7 @@
-- +migrate Up
-- 2023-05-02: Add a last_active column to users, updated whenever the user modifies their profile or members.
-- This is not directly exposed in the API.
-- Potential future use cases: showing total number of active users, pruning completely empty users if they don't log in?
alter table users add column last_active timestamptz not null default now();