fix: accept partially valid batch of heartbeats (resolve #824)

This commit is contained in:
Ferdinand Mütsch
2025-08-08 18:20:11 +02:00
parent 8b8fe21fed
commit ad154294c8
15 changed files with 1269 additions and 1157 deletions

View File

@@ -18,7 +18,8 @@ headers {
}
body:json {
{
[
{
"entity": "/home/user1/dev/proejct1/main.go",
"project": "Project 1",
"language": "Go",
@@ -26,6 +27,17 @@ body:json {
"type": "file",
"category": null,
"branch": null,
"time": 1750701956
}
"time": {{tsNow}}
},
{
"entity": "/home/user1/dev/proejct1/main.go",
"project": "Project 1",
"language": "Go",
"is_write": true,
"type": "file",
"category": null,
"branch": null,
"time": {{ts1MonthAgo}}
}
]
}

View File

@@ -20,6 +20,12 @@ script:pre-request {
function base64encode(str) {
return Buffer.from(str, 'utf-8').toString('base64')
}
bru.setVar('tsNow', parseInt(new Date().getTime() / 1000))
bru.setVar('ts1MinAgo', parseInt((new Date().getTime() - 1000*60*1) / 1000))
bru.setVar('ts2MinAgo', parseInt((new Date().getTime() - 1000*60*2) / 1000))
bru.setVar('ts1HourAgo', parseInt((new Date().getTime() - 1000*60*60*1) / 1000))
bru.setVar('ts1MonthAgo', parseInt((new Date().getTime() - 1000*60*60*24*30*1) / 1000))
}
docs {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
package v1
import (
"github.com/duke-git/lancet/v2/stream"
"net/http"
"strconv"
"time"
@@ -15,6 +17,33 @@ type HeartbeatResponseViewModel struct {
Responses [][]interface{} `json:"responses"`
}
type HeartbeatCreationResult struct {
Data *HeartbeatResponseData
Status int
}
type HeartbeatCreationResults []*HeartbeatCreationResult
func (l HeartbeatCreationResults) All() bool {
return stream.FromSlice(l).AllMatch(func(r *HeartbeatCreationResult) bool {
return r.Status >= 200 && r.Status < 300
})
}
func (l HeartbeatCreationResults) None() bool {
return stream.FromSlice(l).AllMatch(func(r *HeartbeatCreationResult) bool {
return r.Status < 200 || r.Status >= 300
})
}
var HeartbeatSuccess = &HeartbeatCreationResult{
Status: http.StatusCreated,
Data: &HeartbeatResponseData{
Data: nil, // see comment in struct declaration for details
Error: nil,
},
}
type HeartbeatResponseData struct {
// response data actually looks like this: https://pastr.de/p/nyf6kj2e6843fbw4xkj4h4pj
// however, for simplicity, we only implement the top-level fields (and the status code at index 1) and leave them empty

View File

@@ -84,19 +84,23 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
opSys, editor, _ := utils.ParseUserAgent(userAgent)
machineName := r.Header.Get("X-Machine-Name")
for _, hb := range heartbeats {
creationResults := make(v1.HeartbeatCreationResults, len(heartbeats))
for i, hb := range heartbeats {
if hb == nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid heartbeat object"))
return
creationResults[i] = &v1.HeartbeatCreationResult{
Status: http.StatusBadRequest,
Data: &v1.HeartbeatResponseData{Error: "invalid heartbeat object"},
}
continue
}
// TODO: unit test this
if hb.UserAgent != "" {
userAgent = hb.UserAgent
localOpSys, localEditor, _ := utils.ParseUserAgent(userAgent)
opSys = condition.TernaryOperator[bool, string](localOpSys != "", localOpSys, opSys)
editor = condition.TernaryOperator[bool, string](localEditor != "", localEditor, editor)
opSys = condition.Ternary[bool, string](localOpSys != "", localOpSys, opSys)
editor = condition.Ternary[bool, string](localEditor != "", localEditor, editor)
}
if hb.Machine != "" {
machineName = hb.Machine
@@ -112,12 +116,15 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
hb.UserAgent = userAgent
if !hb.Valid() || !hb.Timely(h.config.App.HeartbeatsMaxAge()) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid heartbeat object"))
return
creationResults[i] = &v1.HeartbeatCreationResult{
Status: http.StatusBadRequest,
Data: &v1.HeartbeatResponseData{Error: "invalid heartbeat object"},
}
continue
}
hb.Hashed()
creationResults[i] = v1.HeartbeatSuccess
}
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
@@ -137,27 +144,23 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
}
}
defer func() {}()
if creationResults.None() {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("no valid heartbeat object given"))
return
}
helpers.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(&heartbeats))
helpers.RespondJSON(w, r, http.StatusCreated, makeBulkResponse(creationResults))
}
// construct wakatime response format https://wakatime.com/developers#heartbeats (well, not quite...)
func constructSuccessResponse(heartbeats *[]*models.Heartbeat) *v1.HeartbeatResponseViewModel {
func makeBulkResponse(results []*v1.HeartbeatCreationResult) *v1.HeartbeatResponseViewModel {
vm := &v1.HeartbeatResponseViewModel{
Responses: make([][]interface{}, len(*heartbeats)),
Responses: make([][]interface{}, len(results)),
}
for i := range *heartbeats {
r := make([]interface{}, 2)
r[0] = &v1.HeartbeatResponseData{
Data: nil, // see comment in struct declaration for details
Error: nil,
}
r[1] = http.StatusCreated
vm.Responses[i] = r
for i, r := range results {
vm.Responses[i] = []interface{}{r.Data, r.Status}
}
return vm
}

View File

@@ -12,7 +12,7 @@ server:
app:
aggregation_time: '02:15'
report_time_weekly: 'fri,18:00'
heartbeat_max_age: 87600h # 10 years
heartbeat_max_age: 168h # 1 week
inactive_days: 7
custom_languages:
vue: Vue

View File

@@ -12,7 +12,7 @@ server:
app:
aggregation_time: '02:15'
report_time_weekly: 'fri,18:00'
heartbeat_max_age: 87600h # 10 years
heartbeat_max_age: 168h # 1 week
inactive_days: 7
custom_languages:
vue: Vue

View File

@@ -12,7 +12,7 @@ server:
app:
aggregation_time: '02:15'
report_time_weekly: 'fri,18:00'
heartbeat_max_age: 87600h # 10 years
heartbeat_max_age: 168h # 1 week
inactive_days: 7
custom_languages:
vue: Vue

View File

@@ -12,7 +12,7 @@ server:
app:
aggregation_time: '02:15'
report_time_weekly: 'fri,18:00'
heartbeat_max_age: 87600h # 10 years
heartbeat_max_age: 168h # 1 week
inactive_days: 7
custom_languages:
vue: Vue

View File

@@ -12,7 +12,7 @@ server:
app:
aggregation_time: '02:15'
report_time_weekly: 'fri,18:00'
heartbeat_max_age: 87600h # 10 years
heartbeat_max_age: 168h # 1 week
inactive_days: 7
custom_languages:
vue: Vue

View File

@@ -0,0 +1,33 @@
meta {
name: Create heartbeats (all invalid)
type: http
seq: 11
}
post {
url: {{BASE_URL}}/api/v1/users/current/heartbeats.bulk
body: json
auth: bearer
}
auth:bearer {
token: {{WRITEUSER_TOKEN}}
}
body:json {
[{
"entity": "/home/user1/dev/project1/main.go",
"project": "wakapi",
"language": "Go",
"is_write": true,
"type": "file",
"category": null,
"branch": null,
"time": {{ts4}}
}]
}
assert {
res.status: eq 400
res.body: eq no valid heartbeat object given
}

View File

@@ -34,6 +34,16 @@ body:json {
"category": null,
"branch": null,
"time": {{ts2}}
},
{
"entity": "/home/user1/dev/project1/main.go",
"project": "wakapi",
"language": "Go",
"is_write": true,
"type": "file",
"category": null,
"branch": null,
"time": {{ts4}}
}]
}
@@ -43,10 +53,13 @@ assert {
tests {
test("Response body is correct", function () {
expect(res.body.responses.length).to.eql(2);
expect(res.body.responses.length).to.eql(3);
expect(res.body.responses[0].length).to.eql(2);
expect(res.body.responses[1].length).to.eql(2);
expect(res.body.responses[2].length).to.eql(2);
expect(res.body.responses[0][1]).to.eql(201);
expect(res.body.responses[1][1]).to.eql(201);
expect(res.body.responses[2][0].error).to.not.be.empty;
expect(res.body.responses[2][1]).to.eql(400);
});
}

View File

@@ -23,7 +23,7 @@ body:json {
"type": "file",
"category": null,
"branch": null,
"time": 1640995200
"time": {{tsStartOfYesterday}}
},
{
"entity": "/home/user1/dev/project1/main.go",
@@ -33,7 +33,7 @@ body:json {
"type": "file",
"category": null,
"branch": null,
"time": 1641074400
"time": {{tsLateNightYesterday}}
},
{
"entity": "/home/user1/dev/project1/main.go",
@@ -43,7 +43,7 @@ body:json {
"type": "file",
"category": null,
"branch": null,
"time": 1641081600
"time": {{tsStartOfDay}}
}]
}

View File

@@ -5,13 +5,13 @@ meta {
}
get {
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date=2022-01-01
url: {{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date={{yesterdayDate}}
body: none
auth: bearer
}
params:query {
date: 2022-01-01
date: {{yesterdayDate}}
}
auth:bearer {
@@ -24,8 +24,8 @@ assert {
tests {
test("Response body is correct", function () {
const date = new Date(bru.getVar('yesterdayDateIso'))
expect(res.body.timezone).to.eql(bru.getCollectionVar('TZ'));
var date = new Date("2022-01-01T00:00:00+0100")
expect(new Date(res.body.start)).to.eql(date);
expect(new Date(res.body.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24 - 1000));
expect(res.body.data.length).to.eql(2);

View File

@@ -32,12 +32,15 @@ script:pre-request {
return offset / 1000 / 60;
}
const utcOffset = getTimezoneOffset(userZone);
const utcOffset = getTimezoneOffset(userZone)
bru.setVar('utcOffset', utcOffset)
const now = moment().utcOffset(utcOffset)
const startOfDay = now.clone().startOf('day')
const endOfDay = now.clone().endOf('day')
const endOfTomorrow = now.clone().add(1, 'd').endOf('day')
const startOfYesterday = now.clone().add(-1, 'day').startOf('day')
const endOfYesterday = now.clone().add(-1, 'day').endOf('day')
// Auth stuff
const readApiKey = bru.getCollectionVar('READUSER_API_KEY')
@@ -60,7 +63,11 @@ script:pre-request {
bru.setVar('tsNowMinus2Min', now.clone().add(-2, 'm').format('x') / 1000)
bru.setVar('tsNowMinus3Min', now.clone().add(-3, 'm').format('x') / 1000)
bru.setVar('tsStartOfDay', startOfDay.format('x') / 1000)
bru.setVar('tsLateNightToday', startOfDay.clone().add(22, 'h').format('x') / 1000)
bru.setVar('tsEndOfDay', endOfDay.format('x') / 1000)
bru.setVar('tsStartOfYesterday', startOfYesterday.format('x') / 1000)
bru.setVar('tsLateNightYesterday', startOfYesterday.clone().add(22, 'h').format('x') / 1000)
bru.setVar('tsEndOfYesterday', endOfYesterday.format('x') / 1000)
bru.setVar('tsEndOfTomorrow', endOfTomorrow.format('x') / 1000)
bru.setVar('tsStartOfDayIso', startOfDay.toISOString())
bru.setVar('tsEndOfDayIso', endOfDay.toISOString())
@@ -68,4 +75,9 @@ script:pre-request {
bru.setVar('ts1', now.clone().startOf('hour').format('x') / 1000)
bru.setVar('ts2', now.clone().startOf('hour').add(1, 'm').format('x') / 1000)
bru.setVar('ts3', now.clone().startOf('hour').add(2, 'm').format('x') / 1000)
bru.setVar('ts4', now.clone().add(-1, 'months').format('x') / 1000) // last month
bru.setVar('todayDate', startOfDay.format('YYYY-MM-DD'))
bru.setVar('todayDateIso', startOfDay.toISOString(true))
bru.setVar('yesterdayDate', startOfYesterday.format('YYYY-MM-DD'))
bru.setVar('yesterdayDateIso', startOfYesterday.toISOString(true))
}