Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/pr-labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/labeler@v6
- uses: actions/labeler@v7
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !startsWith(github.actor, 'dependabot') }}
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ For details about compatibility between different releases, see the **Commitment

### Added

- `gs_gateways_disconnected_total` metric, counting gateway disconnections by protocol and by the error the connection was disconnected with. This makes disconnection reasons (such as gateways disappearing without a close handshake, or missing too many pongs) observable as a rate, instead of only through logs.

### Changed

- In the Semtech UDP Packet Forwarder protocol, `PUSH_ACK` and `PULL_ACK` are only sent after the gateway has connected to the Gateway Server and the gateway's `PUSH_DATA` or `PULL_DATA` respectively has been accepted.
- Don't log a `Task failed` warning in GS for every task attached to a gateway connection when that connection is closed. The tasks that run for the lifetime of a gateway connection now stop without an error when the connection is closed, and the disconnection is logged once, as `Disconnected`, including the reason. This removes several duplicate warnings per gateway disconnection, which were particularly noisy for gateways on unreliable backhaul.
- Websocket close errors on the LoRa Basics Station frontend (such as `websocket: close 1006 (abnormal closure): unexpected EOF`, which is what a gateway disappearing without a close handshake looks like) are now reported as the defined error `pkg/gatewayserver/io/semtechws:websocket_closed`, with the close code as an attribute and the original error as the cause. As a result, the `gs.gateway.disconnect` event for these disconnections now carries structured error details instead of a plain string; consumers that parse the event data should expect the `ErrorDetails` format.

### Deprecated

Expand Down
9 changes: 9 additions & 0 deletions config/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -5273,6 +5273,15 @@
"file": "ws.go"
}
},
"error:pkg/gatewayserver/io/semtechws:websocket_closed": {
"translations": {
"en": "websocket closed with code `{code}`"
},
"description": {
"package": "pkg/gatewayserver/io/semtechws",
"file": "ws.go"
}
},
"error:pkg/gatewayserver/io/ttigw:downlink_channel_mixed_bandwidths": {
"translations": {
"en": "downlink channel `{channel}` has mixed bandwidths `{bandwidth_low}` and `{bandwidth_high}` Hz"
Expand Down
17 changes: 13 additions & 4 deletions pkg/gatewayserver/gatewayserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -694,12 +694,19 @@ func (gs *GatewayServer) Connect(

for name, handler := range gs.upstreamHandlers {
connCtx := log.NewContextWithField(conn.Context(), "upstream_handler", name)
handler := handler
gs.StartTask(&task.Config{
Context: connCtx,
ID: fmt.Sprintf("%s_connect_gateway_%s", name, ids.GatewayId),
Func: func(ctx context.Context) error {
return handler.ConnectGateway(ctx, ids, conn)
err := handler.ConnectGateway(ctx, ids, conn)
if err != nil && errors.Is(err, ctx.Err()) {
// Expected stop — the handler returned the error that the context was
// canceled with, so the gateway is disconnected. The reason is reported
// when the connection is torn down, not in this task. Any other error is
// a genuine failure and is reported, even when the context is done.
return nil
}
return err
},
Done: wg.Done,
Restart: task.RestartOnFailure,
Expand Down Expand Up @@ -764,7 +771,9 @@ func (gs *GatewayServer) startDisconnectOnChangeTask(conn connectionEntry) {
d := random.Jitter(gs.config.FetchGatewayInterval, gs.config.FetchGatewayJitter)
select {
case <-ctx.Done():
return ctx.Err()
// Expected stop — the context is done, gateway is disconnected. The reason
// is reported when the connection is torn down, not in this task.
return nil
case <-time.After(d):
}

Expand Down Expand Up @@ -991,7 +1000,7 @@ func (gs *GatewayServer) handleUpstream(ctx context.Context, conn connectionEntr
defer func() {
gs.connections.Delete(unique.ID(ctx, gtw.GetIds()))
registerGatewayDisconnect(ctx, gtw.GetIds(), protocol, ctx.Err())
logger.Info("Disconnected")
logger.WithError(ctx.Err()).Info("Disconnected")
}()

hosts := make([]*upstreamHost, 0, len(gs.upstreamHandlers))
Expand Down
14 changes: 12 additions & 2 deletions pkg/gatewayserver/io/semtechws/ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,18 @@ var (
errGatewayID = errors.DefineInvalidArgument("invalid_gateway_id", "invalid gateway ID `{id}`")
errNoAuthProvided = errors.DefineUnauthenticated("no_auth_provided", "no auth provided `{uid}`")
errMissedTooManyPongs = errors.Define("missed_too_many_pongs", "gateway missed too many pongs")
errWebsocketClosed = errors.DefineAborted("websocket_closed", "websocket closed with code `{code}`")
)

// disconnectError converts err into the error that the connection is disconnected with.
func disconnectError(err error) error {
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
return errWebsocketClosed.WithCause(err).WithAttributes("code", closeErr.Code)
}
return err
}

type srv struct {
ctx context.Context
server io.Server
Expand Down Expand Up @@ -288,7 +298,7 @@ func (s *srv) handleTraffic(w http.ResponseWriter, r *http.Request) (err error)
span.RecordError(err)
span.SetStatus(codes.Error, "handle traffic failed")
}
conn.Disconnect(err)
conn.Disconnect(disconnectError(err))
err = nil // Errors are sent over the websocket connection that is established by this point.
}()

Expand Down Expand Up @@ -346,7 +356,7 @@ func (s *srv) handleTraffic(w http.ResponseWriter, r *http.Request) (err error)
defer ws.Close()
defer func() {
if err != nil {
conn.Disconnect(err)
conn.Disconnect(disconnectError(err))
}
}()
for {
Expand Down
87 changes: 87 additions & 0 deletions pkg/gatewayserver/io/semtechws/ws_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright © 2026 The Things Network Foundation, The Things Industries B.V.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package semtechws

import (
"fmt"
"io"
"testing"

"github.com/gorilla/websocket"
"github.com/smarty/assertions"
"go.thethings.network/lorawan-stack/v3/pkg/errors"
"go.thethings.network/lorawan-stack/v3/pkg/util/test/assertions/should"
)

func TestDisconnectError(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
Name string
Err error
// Code is the expected close code, nil if the error is expected to pass through.
Code any
}{
{
// This is what the gateway disappearing without a close handshake looks like.
Name: "AbnormalClosure",
Err: &websocket.CloseError{Code: websocket.CloseAbnormalClosure, Text: io.ErrUnexpectedEOF.Error()},
Code: websocket.CloseAbnormalClosure,
},
{
Name: "GoingAway",
Err: &websocket.CloseError{Code: websocket.CloseGoingAway},
Code: websocket.CloseGoingAway,
},
{
Name: "WrappedCloseError",
Err: fmt.Errorf("read: %w",
&websocket.CloseError{Code: websocket.CloseNoStatusReceived},
),
Code: websocket.CloseNoStatusReceived,
},
{
Name: "DefinedError",
Err: errMissedTooManyPongs.New(),
},
{
Name: "OtherError",
Err: io.ErrUnexpectedEOF,
},
} {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
a := assertions.New(t)

err := disconnectError(tc.Err)
if tc.Code == nil {
a.So(err, should.Equal, tc.Err)
return
}
if !a.So(errors.IsAborted(err), should.BeTrue) {
t.FailNow()
}
ttnErr, ok := errors.From(err)
if !a.So(ok, should.BeTrue) {
t.FailNow()
}
// The error must be classifiable, so that it can be used as a metric label.
a.So(ttnErr.FullName(), should.Equal, "pkg/gatewayserver/io/semtechws:websocket_closed")
a.So(ttnErr.Attributes()["code"], should.Equal, tc.Code)
// The original error must be preserved for diagnostics.
a.So(errors.Is(err, tc.Err), should.BeTrue)
})
}
}
16 changes: 16 additions & 0 deletions pkg/gatewayserver/observability.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ var gsMetrics = &messageMetrics{
},
[]string{protocol},
),
gatewaysDisconnected: metrics.NewContextualCounterVec(
prometheus.CounterOpts{
Subsystem: subsystem,
Name: "gateways_disconnected_total",
Help: "Total number of gateway disconnections",
},
[]string{protocol, "error"},
),
statusReceived: metrics.NewContextualCounterVec(
prometheus.CounterOpts{
Subsystem: subsystem,
Expand Down Expand Up @@ -256,6 +264,7 @@ func init() {

type messageMetrics struct {
gatewaysConnected *metrics.ContextualGaugeVec
gatewaysDisconnected *metrics.ContextualCounterVec
statusReceived *metrics.ContextualCounterVec
statusForwarded *metrics.ContextualCounterVec
statusDropped *metrics.ContextualCounterVec
Expand All @@ -274,6 +283,7 @@ type messageMetrics struct {

func (m messageMetrics) Describe(ch chan<- *prometheus.Desc) {
m.gatewaysConnected.Describe(ch)
m.gatewaysDisconnected.Describe(ch)
m.statusReceived.Describe(ch)
m.statusForwarded.Describe(ch)
m.statusDropped.Describe(ch)
Expand All @@ -292,6 +302,7 @@ func (m messageMetrics) Describe(ch chan<- *prometheus.Desc) {

func (m messageMetrics) Collect(ch chan<- prometheus.Metric) {
m.gatewaysConnected.Collect(ch)
m.gatewaysDisconnected.Collect(ch)
m.statusReceived.Collect(ch)
m.statusForwarded.Collect(ch)
m.statusDropped.Collect(ch)
Expand Down Expand Up @@ -320,6 +331,11 @@ func registerGatewayConnect(
func registerGatewayDisconnect(ctx context.Context, ids *ttnpb.GatewayIdentifiers, protocol string, err error) {
events.Publish(evtGatewayDisconnect.NewWithIdentifiersAndData(ctx, ids, err))
gsMetrics.gatewaysConnected.WithLabelValues(ctx, protocol).Dec()
errorLabel := unknown
if ttnErr, ok := errors.From(err); ok {
errorLabel = ttnErr.FullName()
}
gsMetrics.gatewaysDisconnected.WithLabelValues(ctx, protocol, errorLabel).Inc()
}

func registerGatewayConnectionStats(ctx context.Context, ids *ttnpb.GatewayIdentifiers, stats *ttnpb.GatewayConnectionStats) {
Expand Down
Loading
Loading