Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/e2e-tests-cypress-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ jobs:
- name: ci/run-summary
id: summary
continue-on-error: true
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@1631d8fcea24f4545a0b3b7f77e41c2fe0be4418 # 2026-07-28
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@e2d5032cfa71a3ad11b0975b135cd221564f2787 # 2026-08-06
with:
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e-tests-playwright-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ jobs:
- name: ci/run-summary
id: summary
continue-on-error: true
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@1631d8fcea24f4545a0b3b7f77e41c2fe0be4418 # 2026-07-28
uses: mattermost/mattermost-test-system-io/.github/actions/test-system-io-summary@e2d5032cfa71a3ad11b0975b135cd221564f2787 # 2026-08-06
with:
use-staging: ${{ vars.E2E_USE_STAGING_TEST_IO_URL != 'false' }}
composite-identity: ${{ needs.prepare-run.outputs.composite-identity-json }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e-tests-playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ jobs:
pull-requests: write
uses: ./.github/workflows/e2e-tests-playwright-template.yml
with:
workers: 15
workers: 20
enabled_docker_services: "postgres inbucket"
commit_sha: ${{ inputs.commit_sha }}
branch: ${{ needs.generate-build-variables.outputs.branch }}
Expand Down
5 changes: 5 additions & 0 deletions server/channels/api4/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,11 @@ func queryLogs(c *Context, w http.ResponseWriter, r *http.Request) {
return
}

if appErr := logFilter.IsValid(); appErr != nil {
c.Err = appErr
return
}

logs, appErr := c.App.QueryLogs(c.AppContext, c.Params.Page, c.Params.LogsPerPage, logFilter)
if appErr != nil {
c.Err = appErr
Expand Down
82 changes: 82 additions & 0 deletions server/channels/api4/system_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,88 @@ func TestGetLogs(t *testing.T) {
CheckUnauthorizedStatus(t, resp)
}

func TestQueryLogs(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)

testID := model.NewId()
expectedMessages := make([]string, 0, 5)
for i := range 5 {
message := fmt.Sprintf("querylogs_verify_%s_%d", testID, i)
expectedMessages = append(expectedMessages, message)
th.TestLogger.Info(message)
}
require.NoError(t, th.TestLogger.Flush(), "failed to flush log")

containsAllMessages := func(combined string) bool {
for _, expected := range expectedMessages {
if !strings.Contains(combined, expected) {
return false
}
}
return true
}

// waitForFilteredLogs queries with the filter, asserting a successful response, and
// polls until the expected messages appear (log availability after Flush is
// asynchronous). It returns the per-node lines from the last response.
waitForFilteredLogs := func(t *testing.T, filter *model.LogFilter) map[string][]json.RawMessage {
t.Helper()
var nodes map[string][]json.RawMessage
require.Eventually(t, func() bool {
decoded, _, err := th.SystemAdminClient.QueryLogs(context.Background(), 0, 200, filter)
if err != nil {
return false
}

var combined strings.Builder
for _, lines := range decoded {
for _, line := range lines {
combined.Write(line)
}
}
if !containsAllMessages(combined.String()) {
return false
}
nodes = decoded
return true
}, 5*time.Second, 25*time.Millisecond, "expected logged messages to be returned")
return nodes
}

t.Run("empty bounds return unfiltered logs", func(t *testing.T) {
nodes := waitForFilteredLogs(t, &model.LogFilter{DateFrom: "", DateTo: ""})
require.Contains(t, nodes, "default")
})

t.Run("valid bounds are accepted and still return matching logs", func(t *testing.T) {
filter := &model.LogFilter{
DateFrom: "2000-01-01 00:00:00.000 +00:00",
DateTo: "2100-01-01 00:00:00.000 +00:00",
}
nodes := waitForFilteredLogs(t, filter)
require.Contains(t, nodes, "default")
})

t.Run("malformed date_from is rejected with 400", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.QueryLogs(context.Background(), 0, 200, &model.LogFilter{DateFrom: "not-a-date", DateTo: ""})
CheckErrorID(t, err, "model.log_filter.is_valid.date_from.app_error")
CheckBadRequestStatus(t, resp)
})

t.Run("malformed date_to is rejected with 400", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.QueryLogs(context.Background(), 0, 200, &model.LogFilter{DateFrom: "", DateTo: "also-not-a-date"})
CheckErrorID(t, err, "model.log_filter.is_valid.date_to.app_error")
CheckBadRequestStatus(t, resp)
})

t.Run("non-admin is forbidden", func(t *testing.T) {
_, resp, err := th.Client.QueryLogs(context.Background(), 0, 200, &model.LogFilter{})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}

func TestDownloadLogs(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
Expand Down
7 changes: 4 additions & 3 deletions server/channels/app/platform/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,16 +326,17 @@ func isLogFilteredByDate(rctx request.CTX, logFilter *model.LogFilter, entry *mo
return false
}

dateFrom, err := time.Parse("2006-01-02 15:04:05.999 -07:00", logFilter.DateFrom)
// Keep parsing aligned with LogFilter.IsValid via the shared layout constant.
dateFrom, err := time.Parse(model.LogFilterDateLayout, logFilter.DateFrom)
if err != nil {
dateFrom = time.Time{}
}
dateTo, err := time.Parse("2006-01-02 15:04:05.999 -07:00", logFilter.DateTo)
dateTo, err := time.Parse(model.LogFilterDateLayout, logFilter.DateTo)
if err != nil {
dateTo = time.Now()
}

timestamp, err := time.Parse("2006-01-02 15:04:05.999 -07:00", entry.Timestamp)
timestamp, err := time.Parse(model.LogFilterDateLayout, entry.Timestamp)
if err != nil {
rctx.Logger().Debug("Cannot parse timestamp, skipping")
return false
Expand Down
8 changes: 8 additions & 0 deletions server/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -12702,6 +12702,14 @@
"id": "model.link_metadata.is_valid.url_length.app_error",
"translation": "Length of link metadata URL is {{ .Length }} characters long, which exceeds the maximum limit of {{ .MaxLength }} characters."
},
{
"id": "model.log_filter.is_valid.date_from.app_error",
"translation": "Invalid date_from filter. Expected the format \"{{.Layout}}\"."
},
{
"id": "model.log_filter.is_valid.date_to.app_error",
"translation": "Invalid date_to filter. Expected the format \"{{.Layout}}\"."
},
{
"id": "model.member.is_valid.channel.app_error",
"translation": "Channel name is not valid"
Expand Down
14 changes: 14 additions & 0 deletions server/public/model/client4.go
Original file line number Diff line number Diff line change
Expand Up @@ -5464,6 +5464,20 @@ func (c *Client4) GetLogs(ctx context.Context, page, perPage int) ([]string, *Re
return DecodeJSONFromResponse[[]string](r)
}

// QueryLogs returns a page of logs, filtered by the given LogFilter, keyed by node id.
func (c *Client4) QueryLogs(ctx context.Context, page, perPage int, filter *LogFilter) (map[string][]json.RawMessage, *Response, error) {
values := url.Values{}
values.Set("page", strconv.Itoa(page))
values.Set("logs_per_page", strconv.Itoa(perPage))

r, err := c.doAPIPostJSONWithQuery(ctx, c.logsRoute().Join("query"), values, filter)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
return DecodeJSONFromResponse[map[string][]json.RawMessage](r)
}

// Download logs as mattermost.log file
func (c *Client4) DownloadLogs(ctx context.Context) ([]byte, *Response, error) {
r, err := c.doAPIGet(ctx, c.logsRoute().Join("download"), "")
Expand Down
28 changes: 28 additions & 0 deletions server/public/model/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package model

import (
"math/big"
"net/http"
"time"
)

const (
Expand Down Expand Up @@ -90,13 +92,39 @@ type AppliedMigration struct {
Name string `json:"name"`
}

// LogFilterDateLayout is the timestamp layout expected for the DateFrom and
// DateTo bounds of a LogFilter.
const LogFilterDateLayout = "2006-01-02 15:04:05.999 -07:00"

type LogFilter struct {
ServerNames []string `json:"server_names"`
LogLevels []string `json:"log_levels"`
DateFrom string `json:"date_from"`
DateTo string `json:"date_to"`
}

// IsValid validates the log filter. Empty date bounds mean "unbounded", so only
// a non-empty value that cannot be parsed with LogFilterDateLayout is rejected.
func (f *LogFilter) IsValid() *AppError {
if f == nil {
return nil
}

if f.DateFrom != "" {
if _, err := time.Parse(LogFilterDateLayout, f.DateFrom); err != nil {
return NewAppError("LogFilter.IsValid", "model.log_filter.is_valid.date_from.app_error", map[string]any{"Layout": LogFilterDateLayout}, "", http.StatusBadRequest).Wrap(err)
}
}

if f.DateTo != "" {
if _, err := time.Parse(LogFilterDateLayout, f.DateTo); err != nil {
return NewAppError("LogFilter.IsValid", "model.log_filter.is_valid.date_to.app_error", map[string]any{"Layout": LogFilterDateLayout}, "", http.StatusBadRequest).Wrap(err)
}
}

return nil
}

type LogEntry struct {
Timestamp string
Level string
Expand Down
86 changes: 86 additions & 0 deletions server/public/model/system_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

package model

import (
"net/http"
"testing"

"github.com/stretchr/testify/require"
)

func TestLogFilterIsValid(t *testing.T) {
validDate := "2020-01-02 15:04:05.000 +00:00"

testCases := []struct {
name string
filter *LogFilter
expectedID string
}{
{
name: "nil filter is valid",
filter: nil,
expectedID: "",
},
{
name: "empty bounds mean unbounded",
filter: &LogFilter{DateFrom: "", DateTo: ""},
expectedID: "",
},
{
name: "valid bounds",
filter: &LogFilter{DateFrom: validDate, DateTo: validDate},
expectedID: "",
},
{
name: "valid from with empty to",
filter: &LogFilter{DateFrom: validDate, DateTo: ""},
expectedID: "",
},
{
name: "valid to with empty from",
filter: &LogFilter{DateFrom: "", DateTo: validDate},
expectedID: "",
},
{
name: "malformed from is rejected",
filter: &LogFilter{DateFrom: "not-a-date", DateTo: ""},
expectedID: "model.log_filter.is_valid.date_from.app_error",
},
{
name: "malformed to is rejected",
filter: &LogFilter{DateFrom: "", DateTo: "also-not-a-date"},
expectedID: "model.log_filter.is_valid.date_to.app_error",
},
{
name: "malformed to is rejected with valid from",
filter: &LogFilter{DateFrom: validDate, DateTo: "also-not-a-date"},
expectedID: "model.log_filter.is_valid.date_to.app_error",
},
{
name: "from is checked before to",
filter: &LogFilter{DateFrom: "not-a-date", DateTo: "also-not-a-date"},
expectedID: "model.log_filter.is_valid.date_from.app_error",
},
{
name: "wrong layout is rejected",
filter: &LogFilter{DateFrom: "2020-01-02T15:04:05Z", DateTo: ""},
expectedID: "model.log_filter.is_valid.date_from.app_error",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
appErr := tc.filter.IsValid()
if tc.expectedID == "" {
require.Nil(t, appErr)
return
}

require.NotNil(t, appErr)
require.Equal(t, tc.expectedID, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
})
}
}
Loading
Loading