feat: expose insights into user activity (#9807)

This commit is contained in:
Marcin Tojek
2023-09-26 18:42:16 +02:00
committed by GitHub
parent 1f4335733c
commit 4c3b579f58
38 changed files with 2148 additions and 70 deletions
+86
View File
@@ -934,6 +934,31 @@ const docTemplate = `{
}
}
},
"/insights/user-activity": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Insights"
],
"summary": "Get insights about user activity",
"operationId": "get-insights-about-user-activity",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserActivityInsightsResponse"
}
}
}
}
},
"/insights/user-latency": {
"get": {
"security": [
@@ -10378,6 +10403,67 @@ const docTemplate = `{
}
}
},
"codersdk.UserActivity": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string",
"format": "uri"
},
"seconds": {
"type": "integer",
"example": 80500
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"user_id": {
"type": "string",
"format": "uuid"
},
"username": {
"type": "string"
}
}
},
"codersdk.UserActivityInsightsReport": {
"type": "object",
"properties": {
"end_time": {
"type": "string",
"format": "date-time"
},
"start_time": {
"type": "string",
"format": "date-time"
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserActivity"
}
}
}
},
"codersdk.UserActivityInsightsResponse": {
"type": "object",
"properties": {
"report": {
"$ref": "#/definitions/codersdk.UserActivityInsightsReport"
}
}
},
"codersdk.UserLatency": {
"type": "object",
"properties": {
+82
View File
@@ -804,6 +804,27 @@
}
}
},
"/insights/user-activity": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Insights"],
"summary": "Get insights about user activity",
"operationId": "get-insights-about-user-activity",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UserActivityInsightsResponse"
}
}
}
}
},
"/insights/user-latency": {
"get": {
"security": [
@@ -9392,6 +9413,67 @@
}
}
},
"codersdk.UserActivity": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string",
"format": "uri"
},
"seconds": {
"type": "integer",
"example": 80500
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"user_id": {
"type": "string",
"format": "uuid"
},
"username": {
"type": "string"
}
}
},
"codersdk.UserActivityInsightsReport": {
"type": "object",
"properties": {
"end_time": {
"type": "string",
"format": "date-time"
},
"start_time": {
"type": "string",
"format": "date-time"
},
"template_ids": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserActivity"
}
}
}
},
"codersdk.UserActivityInsightsResponse": {
"type": "object",
"properties": {
"report": {
"$ref": "#/definitions/codersdk.UserActivityInsightsReport"
}
}
},
"codersdk.UserLatency": {
"type": "object",
"properties": {
+1
View File
@@ -895,6 +895,7 @@ func New(options *Options) *API {
r.Route("/insights", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/daus", api.deploymentDAUs)
r.Get("/user-activity", api.insightsUserActivity)
r.Get("/user-latency", api.insightsUserLatency)
r.Get("/templates", api.insightsTemplates)
})
+19
View File
@@ -1478,6 +1478,25 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License,
return q.db.GetUnexpiredLicenses(ctx)
}
func (q *querier) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
for _, templateID := range arg.TemplateIDs {
template, err := q.db.GetTemplateByID(ctx, templateID)
if err != nil {
return nil, err
}
if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil {
return nil, err
}
}
if len(arg.TemplateIDs) == 0 {
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil {
return nil, err
}
}
return q.db.GetUserActivityInsights(ctx, arg)
}
func (q *querier) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) {
return fetch(q.log, q.auth, q.db.GetUserByEmailOrUsername)(ctx, arg)
}
+136
View File
@@ -2831,6 +2831,142 @@ func (q *FakeQuerier) GetUnexpiredLicenses(_ context.Context) ([]database.Licens
return results, nil
}
func (q *FakeQuerier) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
err := validateDatabaseType(arg)
if err != nil {
return nil, err
}
q.mutex.RLock()
defer q.mutex.RUnlock()
type uniqueKey struct {
TemplateID uuid.UUID
UserID uuid.UUID
}
combinedStats := make(map[uniqueKey]map[time.Time]int64)
// Get application stats
for _, s := range q.workspaceAppStats {
if !(((s.SessionStartedAt.After(arg.StartTime) || s.SessionStartedAt.Equal(arg.StartTime)) && s.SessionStartedAt.Before(arg.EndTime)) ||
(s.SessionEndedAt.After(arg.StartTime) && s.SessionEndedAt.Before(arg.EndTime)) ||
(s.SessionStartedAt.Before(arg.StartTime) && (s.SessionEndedAt.After(arg.EndTime) || s.SessionEndedAt.Equal(arg.EndTime)))) {
continue
}
w, err := q.getWorkspaceByIDNoLock(ctx, s.WorkspaceID)
if err != nil {
return nil, err
}
if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, w.TemplateID) {
continue
}
key := uniqueKey{
TemplateID: w.TemplateID,
UserID: s.UserID,
}
if combinedStats[key] == nil {
combinedStats[key] = make(map[time.Time]int64)
}
t := s.SessionStartedAt.Truncate(time.Minute)
if t.Before(arg.StartTime) {
t = arg.StartTime
}
for t.Before(s.SessionEndedAt) && t.Before(arg.EndTime) {
combinedStats[key][t] = 60
t = t.Add(1 * time.Minute)
}
}
// Get session stats
for _, s := range q.workspaceAgentStats {
if s.CreatedAt.Before(arg.StartTime) || s.CreatedAt.Equal(arg.EndTime) || s.CreatedAt.After(arg.EndTime) {
continue
}
if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) {
continue
}
if s.ConnectionCount == 0 {
continue
}
key := uniqueKey{
TemplateID: s.TemplateID,
UserID: s.UserID,
}
if combinedStats[key] == nil {
combinedStats[key] = make(map[time.Time]int64)
}
if s.SessionCountJetBrains > 0 || s.SessionCountVSCode > 0 || s.SessionCountReconnectingPTY > 0 || s.SessionCountSSH > 0 {
t := s.CreatedAt.Truncate(time.Minute)
combinedStats[key][t] = 60
}
}
// Use temporary maps for aggregation purposes
mUserIDTemplateIDs := map[uuid.UUID]map[uuid.UUID]struct{}{}
mUserIDUsageSeconds := map[uuid.UUID]int64{}
for key, times := range combinedStats {
if mUserIDTemplateIDs[key.UserID] == nil {
mUserIDTemplateIDs[key.UserID] = make(map[uuid.UUID]struct{})
mUserIDUsageSeconds[key.UserID] = 0
}
if _, ok := mUserIDTemplateIDs[key.UserID][key.TemplateID]; !ok {
mUserIDTemplateIDs[key.UserID][key.TemplateID] = struct{}{}
}
for _, t := range times {
mUserIDUsageSeconds[key.UserID] += t
}
}
userIDs := make([]uuid.UUID, 0, len(mUserIDUsageSeconds))
for userID := range mUserIDUsageSeconds {
userIDs = append(userIDs, userID)
}
sort.Slice(userIDs, func(i, j int) bool {
return userIDs[i].String() < userIDs[j].String()
})
// Finally, select stats
var rows []database.GetUserActivityInsightsRow
for _, userID := range userIDs {
user, err := q.getUserByIDNoLock(userID)
if err != nil {
return nil, err
}
tids := mUserIDTemplateIDs[userID]
templateIDs := make([]uuid.UUID, 0, len(tids))
for key := range tids {
templateIDs = append(templateIDs, key)
}
sort.Slice(templateIDs, func(i, j int) bool {
return templateIDs[i].String() < templateIDs[j].String()
})
row := database.GetUserActivityInsightsRow{
UserID: user.ID,
Username: user.Username,
AvatarURL: user.AvatarURL,
TemplateIDs: templateIDs,
UsageSeconds: mUserIDUsageSeconds[userID],
}
rows = append(rows, row)
}
return rows, nil
}
func (q *FakeQuerier) GetUserByEmailOrUsername(_ context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) {
if err := validateDatabaseType(arg); err != nil {
return database.User{}, err
+7
View File
@@ -774,6 +774,13 @@ func (m metricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database.Lice
return licenses, err
}
func (m metricsStore) GetUserActivityInsights(ctx context.Context, arg database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
start := time.Now()
r0, r1 := m.s.GetUserActivityInsights(ctx, arg)
m.queryLatencies.WithLabelValues("GetUserActivityInsights").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetUserByEmailOrUsername(ctx context.Context, arg database.GetUserByEmailOrUsernameParams) (database.User, error) {
start := time.Now()
user, err := m.s.GetUserByEmailOrUsername(ctx, arg)
+15
View File
@@ -1598,6 +1598,21 @@ func (mr *MockStoreMockRecorder) GetUnexpiredLicenses(arg0 interface{}) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnexpiredLicenses", reflect.TypeOf((*MockStore)(nil).GetUnexpiredLicenses), arg0)
}
// GetUserActivityInsights mocks base method.
func (m *MockStore) GetUserActivityInsights(arg0 context.Context, arg1 database.GetUserActivityInsightsParams) ([]database.GetUserActivityInsightsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserActivityInsights", arg0, arg1)
ret0, _ := ret[0].([]database.GetUserActivityInsightsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserActivityInsights indicates an expected call of GetUserActivityInsights.
func (mr *MockStoreMockRecorder) GetUserActivityInsights(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserActivityInsights", reflect.TypeOf((*MockStore)(nil).GetUserActivityInsights), arg0, arg1)
}
// GetUserByEmailOrUsername mocks base method.
func (m *MockStore) GetUserByEmailOrUsername(arg0 context.Context, arg1 database.GetUserByEmailOrUsernameParams) (database.User, error) {
m.ctrl.T.Helper()
+8
View File
@@ -153,6 +153,14 @@ type sqlcQuerier interface {
GetTemplates(ctx context.Context) ([]Template, error)
GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error)
GetUnexpiredLicenses(ctx context.Context) ([]License, error)
// GetUserActivityInsights returns the ranking with top active users.
// The result can be filtered on template_ids, meaning only user data from workspaces
// based on those templates will be included.
// Note: When selecting data from multiple templates or the entire deployment,
// be aware that it may lead to an increase in "usage" numbers (cumulative). In such cases,
// users may be counted multiple times for the same time interval if they have used multiple templates
// simultaneously.
GetUserActivityInsights(ctx context.Context, arg GetUserActivityInsightsParams) ([]GetUserActivityInsightsRow, error)
GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error)
GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
GetUserCount(ctx context.Context) (int64, error)
+121
View File
@@ -2022,6 +2022,127 @@ func (q *sqlQuerier) GetTemplateParameterInsights(ctx context.Context, arg GetTe
return items, nil
}
const getUserActivityInsights = `-- name: GetUserActivityInsights :many
WITH app_stats AS (
SELECT
s.start_time,
was.user_id,
w.template_id,
60 as seconds
FROM workspace_app_stats was
JOIN workspaces w ON (
w.id = was.workspace_id
AND CASE WHEN COALESCE(array_length($1::uuid[], 1), 0) > 0 THEN w.template_id = ANY($1::uuid[]) ELSE TRUE END
)
-- This table contains both 1 minute entries and >1 minute entries,
-- to calculate this with our uniqueness constraints, we generate series
-- for the longer intervals.
CROSS JOIN LATERAL generate_series(
date_trunc('minute', was.session_started_at),
-- Subtract 1 microsecond to avoid creating an extra series.
date_trunc('minute', was.session_ended_at - '1 microsecond'::interval),
'1 minute'::interval
) s(start_time)
WHERE
s.start_time >= $2::timestamptz
-- Subtract one minute because the series only contains the start time.
AND s.start_time < ($3::timestamptz) - '1 minute'::interval
GROUP BY s.start_time, w.template_id, was.user_id
), session_stats AS (
SELECT
date_trunc('minute', was.created_at) as start_time,
was.user_id,
was.template_id,
CASE WHEN
SUM(was.session_count_vscode) > 0 OR
SUM(was.session_count_jetbrains) > 0 OR
SUM(was.session_count_reconnecting_pty) > 0 OR
SUM(was.session_count_ssh) > 0
THEN 60 ELSE 0 END as seconds
FROM workspace_agent_stats was
WHERE
was.created_at >= $2::timestamptz
AND was.created_at < $3::timestamptz
AND was.connection_count > 0
AND CASE WHEN COALESCE(array_length($1::uuid[], 1), 0) > 0 THEN was.template_id = ANY($1::uuid[]) ELSE TRUE END
GROUP BY date_trunc('minute', was.created_at), was.user_id, was.template_id
), combined_stats AS (
SELECT
user_id,
template_id,
start_time,
seconds
FROM app_stats
UNION
SELECT
user_id,
template_id,
start_time,
seconds
FROM session_stats
)
SELECT
users.id as user_id,
users.username,
users.avatar_url,
array_agg(DISTINCT template_id)::uuid[] AS template_ids,
SUM(seconds) AS usage_seconds
FROM combined_stats
JOIN users ON (users.id = combined_stats.user_id)
GROUP BY users.id, username, avatar_url
ORDER BY user_id ASC
`
type GetUserActivityInsightsParams struct {
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
StartTime time.Time `db:"start_time" json:"start_time"`
EndTime time.Time `db:"end_time" json:"end_time"`
}
type GetUserActivityInsightsRow struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Username string `db:"username" json:"username"`
AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"`
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
UsageSeconds int64 `db:"usage_seconds" json:"usage_seconds"`
}
// GetUserActivityInsights returns the ranking with top active users.
// The result can be filtered on template_ids, meaning only user data from workspaces
// based on those templates will be included.
// Note: When selecting data from multiple templates or the entire deployment,
// be aware that it may lead to an increase in "usage" numbers (cumulative). In such cases,
// users may be counted multiple times for the same time interval if they have used multiple templates
// simultaneously.
func (q *sqlQuerier) GetUserActivityInsights(ctx context.Context, arg GetUserActivityInsightsParams) ([]GetUserActivityInsightsRow, error) {
rows, err := q.db.QueryContext(ctx, getUserActivityInsights, pq.Array(arg.TemplateIDs), arg.StartTime, arg.EndTime)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUserActivityInsightsRow
for rows.Next() {
var i GetUserActivityInsightsRow
if err := rows.Scan(
&i.UserID,
&i.Username,
&i.AvatarURL,
pq.Array(&i.TemplateIDs),
&i.UsageSeconds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUserLatencyInsights = `-- name: GetUserLatencyInsights :many
SELECT
workspace_agent_stats.user_id,
+77
View File
@@ -21,6 +21,83 @@ WHERE
GROUP BY workspace_agent_stats.user_id, users.username, users.avatar_url
ORDER BY user_id ASC;
-- name: GetUserActivityInsights :many
-- GetUserActivityInsights returns the ranking with top active users.
-- The result can be filtered on template_ids, meaning only user data from workspaces
-- based on those templates will be included.
-- Note: When selecting data from multiple templates or the entire deployment,
-- be aware that it may lead to an increase in "usage" numbers (cumulative). In such cases,
-- users may be counted multiple times for the same time interval if they have used multiple templates
-- simultaneously.
WITH app_stats AS (
SELECT
s.start_time,
was.user_id,
w.template_id,
60 as seconds
FROM workspace_app_stats was
JOIN workspaces w ON (
w.id = was.workspace_id
AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN w.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END
)
-- This table contains both 1 minute entries and >1 minute entries,
-- to calculate this with our uniqueness constraints, we generate series
-- for the longer intervals.
CROSS JOIN LATERAL generate_series(
date_trunc('minute', was.session_started_at),
-- Subtract 1 microsecond to avoid creating an extra series.
date_trunc('minute', was.session_ended_at - '1 microsecond'::interval),
'1 minute'::interval
) s(start_time)
WHERE
s.start_time >= @start_time::timestamptz
-- Subtract one minute because the series only contains the start time.
AND s.start_time < (@end_time::timestamptz) - '1 minute'::interval
GROUP BY s.start_time, w.template_id, was.user_id
), session_stats AS (
SELECT
date_trunc('minute', was.created_at) as start_time,
was.user_id,
was.template_id,
CASE WHEN
SUM(was.session_count_vscode) > 0 OR
SUM(was.session_count_jetbrains) > 0 OR
SUM(was.session_count_reconnecting_pty) > 0 OR
SUM(was.session_count_ssh) > 0
THEN 60 ELSE 0 END as seconds
FROM workspace_agent_stats was
WHERE
was.created_at >= @start_time::timestamptz
AND was.created_at < @end_time::timestamptz
AND was.connection_count > 0
AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN was.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END
GROUP BY date_trunc('minute', was.created_at), was.user_id, was.template_id
), combined_stats AS (
SELECT
user_id,
template_id,
start_time,
seconds
FROM app_stats
UNION
SELECT
user_id,
template_id,
start_time,
seconds
FROM session_stats
)
SELECT
users.id as user_id,
users.username,
users.avatar_url,
array_agg(DISTINCT template_id)::uuid[] AS template_ids,
SUM(seconds) AS usage_seconds
FROM combined_stats
JOIN users ON (users.id = combined_stats.user_id)
GROUP BY users.id, username, avatar_url
ORDER BY user_id ASC;
-- name: GetTemplateInsights :one
-- GetTemplateInsights has a granularity of 5 minutes where if a session/app was
-- in use during a minute, we will add 5 minutes to the total usage for that
+87
View File
@@ -59,6 +59,93 @@ func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Get insights about user activity
// @ID get-insights-about-user-activity
// @Security CoderSessionToken
// @Produce json
// @Tags Insights
// @Success 200 {object} codersdk.UserActivityInsightsResponse
// @Router /insights/user-activity [get]
func (api *API) insightsUserActivity(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := httpapi.NewQueryParamParser().
Required("start_time").
Required("end_time")
vals := r.URL.Query()
var (
// The QueryParamParser does not preserve timezone, so we need
// to parse the time ourselves.
startTimeString = p.String(vals, "", "start_time")
endTimeString = p.String(vals, "", "end_time")
templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids")
)
p.ErrorExcessParams(vals)
if len(p.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Validations: p.Errors,
})
return
}
startTime, endTime, ok := parseInsightsStartAndEndTime(ctx, rw, startTimeString, endTimeString)
if !ok {
return
}
rows, err := api.Database.GetUserActivityInsights(ctx, database.GetUserActivityInsightsParams{
StartTime: startTime,
EndTime: endTime,
TemplateIDs: templateIDs,
})
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user activity.",
Detail: err.Error(),
})
return
}
templateIDSet := make(map[uuid.UUID]struct{})
userActivities := make([]codersdk.UserActivity, 0, len(rows))
for _, row := range rows {
for _, templateID := range row.TemplateIDs {
templateIDSet[templateID] = struct{}{}
}
userActivities = append(userActivities, codersdk.UserActivity{
TemplateIDs: row.TemplateIDs,
UserID: row.UserID,
Username: row.Username,
AvatarURL: row.AvatarURL.String,
Seconds: row.UsageSeconds,
})
}
// TemplateIDs that contributed to the data.
seenTemplateIDs := make([]uuid.UUID, 0, len(templateIDSet))
for templateID := range templateIDSet {
seenTemplateIDs = append(seenTemplateIDs, templateID)
}
slices.SortFunc(seenTemplateIDs, func(a, b uuid.UUID) int {
return slice.Ascending(a.String(), b.String())
})
resp := codersdk.UserActivityInsightsResponse{
Report: codersdk.UserActivityInsightsReport{
StartTime: startTime,
EndTime: endTime,
TemplateIDs: seenTemplateIDs,
Users: userActivities,
},
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Get insights about user latency
// @ID get-insights-about-user-latency
// @Security CoderSessionToken
+980 -70
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"report": {
"start_time": "2023-08-01T00:00:00Z",
"end_time": "2023-08-22T00:00:00Z",
"template_ids": [
"00000000-0000-0000-0000-000000000002"
],
"users": [
{
"template_ids": [
"00000000-0000-0000-0000-000000000002"
],
"user_id": "00000000-0000-0000-0000-000000000004",
"username": "user1",
"avatar_url": "",
"seconds": 32400
}
]
}
}
@@ -0,0 +1,41 @@
{
"report": {
"start_time": "2023-08-15T00:00:00Z",
"end_time": "2023-08-22T00:00:00Z",
"template_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003"
],
"users": [
{
"template_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002"
],
"user_id": "00000000-0000-0000-0000-000000000004",
"username": "user1",
"avatar_url": "",
"seconds": 30540
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000001"
],
"user_id": "00000000-0000-0000-0000-000000000005",
"username": "user2",
"avatar_url": "",
"seconds": 3600
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000003"
],
"user_id": "00000000-0000-0000-0000-000000000006",
"username": "user3",
"avatar_url": "",
"seconds": 4200
}
]
}
}
@@ -0,0 +1,41 @@
{
"report": {
"start_time": "2023-08-15T00:00:00Z",
"end_time": "2023-08-22T00:00:00Z",
"template_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003"
],
"users": [
{
"template_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002"
],
"user_id": "00000000-0000-0000-0000-000000000004",
"username": "user1",
"avatar_url": "",
"seconds": 30540
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000001"
],
"user_id": "00000000-0000-0000-0000-000000000005",
"username": "user2",
"avatar_url": "",
"seconds": 3600
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000003"
],
"user_id": "00000000-0000-0000-0000-000000000006",
"username": "user3",
"avatar_url": "",
"seconds": 4200
}
]
}
}
@@ -0,0 +1,29 @@
{
"report": {
"start_time": "2023-08-15T00:00:00Z",
"end_time": "2023-08-22T00:00:00Z",
"template_ids": [
"00000000-0000-0000-0000-000000000001"
],
"users": [
{
"template_ids": [
"00000000-0000-0000-0000-000000000001"
],
"user_id": "00000000-0000-0000-0000-000000000004",
"username": "user1",
"avatar_url": "",
"seconds": 5340
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000001"
],
"user_id": "00000000-0000-0000-0000-000000000005",
"username": "user2",
"avatar_url": "",
"seconds": 3600
}
]
}
}
@@ -0,0 +1,41 @@
{
"report": {
"start_time": "2023-08-15T00:00:00-03:00",
"end_time": "2023-08-22T00:00:00-03:00",
"template_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003"
],
"users": [
{
"template_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002"
],
"user_id": "00000000-0000-0000-0000-000000000004",
"username": "user1",
"avatar_url": "",
"seconds": 23280
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000001"
],
"user_id": "00000000-0000-0000-0000-000000000005",
"username": "user2",
"avatar_url": "",
"seconds": 3600
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000003"
],
"user_id": "00000000-0000-0000-0000-000000000006",
"username": "user3",
"avatar_url": "",
"seconds": 600
}
]
}
}
@@ -0,0 +1,20 @@
{
"report": {
"start_time": "2023-08-15T00:00:00Z",
"end_time": "2023-08-22T00:00:00Z",
"template_ids": [
"00000000-0000-0000-0000-000000000002"
],
"users": [
{
"template_ids": [
"00000000-0000-0000-0000-000000000002"
],
"user_id": "00000000-0000-0000-0000-000000000004",
"username": "user1",
"avatar_url": "",
"seconds": 25200
}
]
}
}
@@ -0,0 +1,20 @@
{
"report": {
"start_time": "2023-08-15T00:00:00Z",
"end_time": "2023-08-22T00:00:00Z",
"template_ids": [
"00000000-0000-0000-0000-000000000003"
],
"users": [
{
"template_ids": [
"00000000-0000-0000-0000-000000000003"
],
"user_id": "00000000-0000-0000-0000-000000000006",
"username": "user3",
"avatar_url": "",
"seconds": 4200
}
]
}
}
@@ -0,0 +1,41 @@
{
"report": {
"start_time": "2023-08-12T00:00:00Z",
"end_time": "2023-08-19T00:00:00Z",
"template_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003"
],
"users": [
{
"template_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002"
],
"user_id": "00000000-0000-0000-0000-000000000004",
"username": "user1",
"avatar_url": "",
"seconds": 29820
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000001"
],
"user_id": "00000000-0000-0000-0000-000000000005",
"username": "user2",
"avatar_url": "",
"seconds": 7200
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000003"
],
"user_id": "00000000-0000-0000-0000-000000000006",
"username": "user3",
"avatar_url": "",
"seconds": 4200
}
]
}
}
@@ -0,0 +1,29 @@
{
"report": {
"start_time": "2023-08-15T00:00:00Z",
"end_time": "2023-08-22T00:00:00Z",
"template_ids": [
"00000000-0000-0000-0000-000000000001"
],
"users": [
{
"template_ids": [
"00000000-0000-0000-0000-000000000001"
],
"user_id": "00000000-0000-0000-0000-000000000004",
"username": "user1",
"avatar_url": "",
"seconds": 5340
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000001"
],
"user_id": "00000000-0000-0000-0000-000000000005",
"username": "user2",
"avatar_url": "",
"seconds": 3600
}
]
}
}
@@ -0,0 +1,41 @@
{
"report": {
"start_time": "2023-08-14T00:00:00Z",
"end_time": "2023-08-21T00:00:00Z",
"template_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003"
],
"users": [
{
"template_ids": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002"
],
"user_id": "00000000-0000-0000-0000-000000000004",
"username": "user1",
"avatar_url": "",
"seconds": 29820
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000001"
],
"user_id": "00000000-0000-0000-0000-000000000005",
"username": "user2",
"avatar_url": "",
"seconds": 7200
},
{
"template_ids": [
"00000000-0000-0000-0000-000000000003"
],
"user_id": "00000000-0000-0000-0000-000000000006",
"username": "user3",
"avatar_url": "",
"seconds": 4200
}
]
}
}