feat: add last active time per user
This commit is contained in:
		
							parent
							
								
									90c7dcf891
								
							
						
					
					
						commit
						cf95e69349
					
				
					 8 changed files with 85 additions and 234 deletions
				
			
		|  | @ -6,8 +6,10 @@ import ( | |||
| 
 | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/log" | ||||
| 	"emperror.dev/errors" | ||||
| 	"github.com/jackc/pgx/v5/pgconn" | ||||
| 	"github.com/prometheus/client_golang/prometheus" | ||||
| 	"github.com/prometheus/client_golang/prometheus/promauto" | ||||
| 	"github.com/rs/xid" | ||||
| ) | ||||
| 
 | ||||
| func (db *DB) initMetrics() (err error) { | ||||
|  | @ -39,6 +41,20 @@ func (db *DB) initMetrics() (err error) { | |||
| 		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{ | ||||
| 		Name: "pronouns_database_latency", | ||||
| 		Help: "The latency to the database in nanoseconds", | ||||
|  | @ -51,6 +67,9 @@ func (db *DB) initMetrics() (err error) { | |||
| 		} | ||||
| 		return float64(time.Since(start)) | ||||
| 	})) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "registering database latency gauge") | ||||
| 	} | ||||
| 
 | ||||
| 	db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{ | ||||
| 		Name: "pronouns_api_requests_total", | ||||
|  | @ -75,3 +94,32 @@ func (db *DB) TotalMemberCount(ctx context.Context) (numMembers int64, err error | |||
| 	} | ||||
| 	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 | ||||
| } | ||||
|  |  | |||
|  | @ -24,6 +24,7 @@ type User struct { | |||
| 	DisplayName *string | ||||
| 	Bio         *string | ||||
| 	MemberTitle *string | ||||
| 	LastActive  time.Time | ||||
| 
 | ||||
| 	Avatar *string | ||||
| 	Links  []string | ||||
|  |  | |||
|  | @ -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) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "committing transaction") | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ import ( | |||
| 	"github.com/rs/xid" | ||||
| 
 | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/db" | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/log" | ||||
| 	"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) | ||||
| 	return nil | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("committing transaction: %v", err) | ||||
|  |  | |||
|  | @ -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) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("committing transaction: %v", err) | ||||
|  |  | |||
							
								
								
									
										234
									
								
								openapi.yml
									
										
									
									
									
								
							
							
						
						
									
										234
									
								
								openapi.yml
									
										
									
									
									
								
							|  | @ -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. | ||||
							
								
								
									
										7
									
								
								scripts/migrate/016_user_activity.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								scripts/migrate/016_user_activity.sql
									
										
									
									
									
										Normal 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(); | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue