From 57aebfd0c3a69a6ca0ac139118124c5769fcef4d Mon Sep 17 00:00:00 2001 From: "Diogo Paulo (dpa)" Date: Mon, 30 Mar 2026 14:03:44 +0100 Subject: [PATCH 1/4] Add observability operation, to get json like information from the tunnel connections --- go.mod | 1 + go.sum | 2 + main.go | 92 +++++++++++++++++++++++++++ main_test.go | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+) diff --git a/go.mod b/go.mod index ba0bbae..479ede9 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,7 @@ module github.com/outsystems/cloud-connector require ( github.com/go-resty/resty/v2 v2.17.2 + github.com/google/uuid v1.6.0 github.com/jarcoal/httpmock v1.4.1 github.com/jpillora/chisel v1.10.1 ) diff --git a/go.sum b/go.sum index bc0f610..d00fc74 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= diff --git a/main.go b/main.go index d95a338..fdb10d4 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "flag" "fmt" "log" @@ -14,6 +15,7 @@ import ( "math/rand" "github.com/go-resty/resty/v2" + "github.com/google/uuid" chclient "github.com/jpillora/chisel/client" "github.com/jpillora/chisel/share/cos" @@ -61,6 +63,67 @@ func (flag *headerFlags) Set(arg string) error { return nil } +type jsonEvent struct { + CorrelationID string `json:"correlation_id"` + Time float64 `json:"time"` + Host string `json:"host"` + Source string `json:"source"` + Sourcetype string `json:"source_type"` + Event tunnelEvent `json:"event"` +} + +type tunnelEvent struct { + Version string `json:"version"` + EventType string `json:"event_type"` + Server string `json:"server"` + Remotes []string `json:"remotes"` + DestinationHosts []string `json:"destination_hosts"` + Status string `json:"status"` + LatencyMs *int64 `json:"latency_ms"` // null when not yet known + Error *string `json:"error"` // null on success +} + +func emitObsEvent(correlationID, eventType, status, server string, remotes []string, + destHosts []string, latencyMs *int64, obsErr *string) { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "unknown" + } + ev := jsonEvent{ + CorrelationID: correlationID, + Time: float64(time.Now().UnixMilli()) / 1000.0, + Host: hostname, + Source: "outsystemscc", + Sourcetype: "outsystemscc:tunnel", + Event: tunnelEvent{ + Version: version, + EventType: eventType, + Server: server, + Remotes: remotes, + DestinationHosts: destHosts, + Status: status, + LatencyMs: latencyMs, + Error: obsErr, + }, + } + data, err := json.Marshal(ev) + if err != nil { + log.Printf("[WARN] observability: failed to marshal event: %v\n", err) + return + } + fmt.Println(string(data)) +} + +func extractDestinationHosts(remotes []string) []string { + hosts := make([]string, 0, len(remotes)) + for _, r := range remotes { + if decoded, err := settings.DecodeRemote(r); err == nil { + hosts = append(hosts, decoded.RemoteHost) + } + } + return hosts +} + var clientHelp = ` Usage: outsystemscc [options] [remote] [remote] ... @@ -106,6 +169,10 @@ var clientHelp = ` --pid Generate pid file in current working directory + -o, Emit JSON events to stdout at key tunnel lifecycle points (starting, + connected, disconnected, error). Each event is a single-line JSON object + including destination hosts, connection status, and latency. + -v, Enable verbose logging --help, This help text @@ -130,6 +197,7 @@ func client(args []string) { hostname := flags.String("hostname", "", "Deprecated, will be ignored") pid := flags.Bool("pid", false, "") verbose := flags.Bool("v", false, "") + observability := flags.Bool("o", false, "") flags.Usage = func() { fmt.Print(clientHelp) os.Exit(0) @@ -160,6 +228,14 @@ func client(args []string) { config.Server = fmt.Sprintf("%s%s", serverURL, queryParams) config.Remotes = args[1:] + var destHosts []string + var correlationID string + if *observability { + destHosts = extractDestinationHosts(args[1:]) + correlationID = uuid.New().String() + emitObsEvent(correlationID, "tunnel_starting", "starting", serverURL, args[1:], destHosts, nil, nil) + } + //default auth if config.Auth == "" { config.Auth = os.Getenv("AUTH") @@ -180,12 +256,28 @@ func client(args []string) { } go cos.GoStats() ctx := cos.InterruptContext() + connectStart := time.Now() if err := c.Start(ctx); err != nil { + if *observability { + errStr := err.Error() + emitObsEvent(correlationID, "tunnel_error", "error", serverURL, args[1:], destHosts, nil, &errStr) + } log.Fatal(err) } + if *observability { + ms := time.Since(connectStart).Milliseconds() + emitObsEvent(correlationID, "tunnel_connected", "connected", serverURL, args[1:], destHosts, &ms, nil) + } if err := c.Wait(); err != nil { + if *observability { + errStr := err.Error() + emitObsEvent(correlationID, "tunnel_error", "error", serverURL, args[1:], destHosts, nil, &errStr) + } log.Fatal(err) } + if *observability { + emitObsEvent(correlationID, "tunnel_disconnected", "disconnected", serverURL, args[1:], destHosts, nil, nil) + } } func createHTTPClient(config *chclient.Config) *resty.Client { diff --git a/main_test.go b/main_test.go index bee136b..30ef65c 100644 --- a/main_test.go +++ b/main_test.go @@ -1,7 +1,9 @@ package main import ( + "encoding/json" "net/http" + "os" "testing" "strings" @@ -10,6 +12,179 @@ import ( "github.com/jarcoal/httpmock" ) +func Test_emitObsEvent(t *testing.T) { + const testCorrelationID = "550e8400-e29b-41d4-a716-446655440000" + tests := []struct { + name string + eventType string + status string + server string + remotes []string + destHosts []string + latencyMs *int64 + obsErr *string + wantEventType string + wantStatus string + wantLatency bool // true = expect non-null latency_ms + wantErr bool // true = expect non-null error + }{ + { + name: "tunnel_starting no latency no error", + eventType: "tunnel_starting", + status: "starting", + server: "wss://pg.example.com", + remotes: []string{"R:8081:db.internal:5432"}, + destHosts: []string{"db.internal"}, + latencyMs: nil, + obsErr: nil, + wantEventType: "tunnel_starting", + wantStatus: "starting", + wantLatency: false, + wantErr: false, + }, + { + name: "tunnel_connected with latency", + eventType: "tunnel_connected", + status: "connected", + server: "wss://pg.example.com", + remotes: []string{"R:8081:db.internal:5432"}, + destHosts: []string{"db.internal"}, + latencyMs: func() *int64 { v := int64(266); return &v }(), + obsErr: nil, + wantEventType: "tunnel_connected", + wantStatus: "connected", + wantLatency: true, + wantErr: false, + }, + { + name: "tunnel_error with error string", + eventType: "tunnel_error", + status: "error", + server: "wss://pg.example.com", + remotes: []string{"R:8081:db.internal:5432"}, + destHosts: []string{"db.internal"}, + latencyMs: nil, + obsErr: func() *string { s := "connection refused"; return &s }(), + wantEventType: "tunnel_error", + wantStatus: "error", + wantLatency: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Arrange: redirect stdout to a pipe + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error: %v", err) + } + origStdout := os.Stdout + os.Stdout = w + + // Act + emitObsEvent(testCorrelationID, tt.eventType, tt.status, tt.server, tt.remotes, tt.destHosts, tt.latencyMs, tt.obsErr) + + // Restore stdout and read output + w.Close() + os.Stdout = origStdout + buf := make([]byte, 4096) + n, _ := r.Read(buf) + r.Close() + output := strings.TrimSpace(string(buf[:n])) + + // Assert: valid JSON + var ev jsonEvent + if jsonErr := json.Unmarshal([]byte(output), &ev); jsonErr != nil { + t.Fatalf("output is not valid JSON: %v\noutput: %s", jsonErr, output) + } + + if ev.Sourcetype != "outsystemscc:tunnel" { + t.Errorf("source_type = %q, want %q", ev.Sourcetype, "outsystemscc:tunnel") + } + if ev.Source != "outsystemscc" { + t.Errorf("source = %q, want %q", ev.Source, "outsystemscc") + } + if ev.Host == "" { + t.Errorf("host is empty") + } + if ev.CorrelationID != testCorrelationID { + t.Errorf("correlation_id = %q, want %q", ev.CorrelationID, testCorrelationID) + } + if ev.Event.EventType != tt.wantEventType { + t.Errorf("event_type = %q, want %q", ev.Event.EventType, tt.wantEventType) + } + if ev.Event.Status != tt.wantStatus { + t.Errorf("status = %q, want %q", ev.Event.Status, tt.wantStatus) + } + if tt.wantLatency && ev.Event.LatencyMs == nil { + t.Errorf("latency_ms is nil, want non-nil") + } + if !tt.wantLatency && ev.Event.LatencyMs != nil { + t.Errorf("latency_ms = %v, want nil", *ev.Event.LatencyMs) + } + if tt.wantErr && ev.Event.Error == nil { + t.Errorf("error is nil, want non-nil") + } + if !tt.wantErr && ev.Event.Error != nil { + t.Errorf("error = %q, want nil", *ev.Event.Error) + } + }) + } +} + +func Test_extractDestinationHosts(t *testing.T) { + tests := []struct { + name string + remotes []string + want []string + }{ + { + name: "single valid remote", + remotes: []string{"R:8081:db.internal:5432"}, + want: []string{"db.internal"}, + }, + { + name: "multiple valid remotes", + remotes: []string{"R:8081:db.internal:5432", "R:8082:cache.internal:6379"}, + want: []string{"db.internal", "cache.internal"}, + }, + { + name: "invalid remote is skipped", + remotes: []string{"not-a-valid-remote"}, + want: []string{}, + }, + { + name: "mix of valid and invalid remotes", + remotes: []string{"R:8081:db.internal:5432", "not-valid"}, + want: []string{"db.internal"}, + }, + { + name: "empty remotes", + remotes: []string{}, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Act + got := extractDestinationHosts(tt.remotes) + + // Assert + if len(got) != len(tt.want) { + t.Errorf("extractDestinationHosts() len = %d, want %d; got %v", len(got), len(tt.want), got) + return + } + for i, h := range got { + if h != tt.want[i] { + t.Errorf("extractDestinationHosts()[%d] = %q, want %q", i, h, tt.want[i]) + } + } + }) + } +} + func Test_validateRemotes(t *testing.T) { tests := []struct { From 8fd989127266292a942f4ad62b416e2f35983709 Mon Sep 17 00:00:00 2001 From: "Diogo Paulo (dpa)" Date: Mon, 30 Mar 2026 14:42:57 +0100 Subject: [PATCH 2/4] Changed README. Cleaned some redundant information from the logs --- README.md | 59 +++++++++++++++++++++ main.go | 66 +++++++++--------------- main_test.go | 141 ++++++++++++++------------------------------------- 3 files changed, 121 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index 463615f..c30b3cc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ OutSystems Cloud Connector * [Firewall setup](#firewall-setup) 1. [Usage](#usage) * [Logging](#logging) + * [Observability](#observability) 1. [Detailed options](#detailed-options) 1. [License](#license) @@ -178,6 +179,60 @@ You can redirect this output to a file for retention purposes. For example: If your organization uses a centralized log management product, see its documentation about how to redirect the log output. +### Observability (`-o`) + +When you pass **`-o`** on the command line, `outsystemscc` turns on observability for this run: it prints **one JSON object per line** to stdout at tunnel lifecycle points. Use this mode when you want machine‑parseable events (for example shipping lines to Splunk, Elastic, or another log platform) alongside or instead of reading the human‑readable log lines above. + +Each event includes a **`correlation_id`** that stays the same for all events emitted during a single process run, so you can tie lifecycle updates (see **`status`** below) to the same operation. + +Top-level fields on every line: + +| Field | Meaning | +| --- | --- | +| `correlation_id` | UUID for this connector run | +| `time` | Unix time in seconds with fractional part | +| `host` | Hostname of the machine running `outsystemscc` | +| `source` | Always `outsystemscc` | +| `source_type` | Always `outsystemscc:tunnel` | +| `event` | Nested object with tunnel details (see below) | + +The nested **`event`** object contains: + +| Field | Meaning | +| --- | --- | +| `version` | `outsystemscc` build version | +| `status` | Lifecycle state: `starting`, `connected`, `disconnected`, or `error` | +| `server` | Resolved Private Gateway server URL used for the tunnel | +| `remotes` | Remote specs as passed on the command line (e.g. `R:8081:10.0.0.1:8393`) | +| `latency_ms` | Round-trip time in ms when connected; `null` if not applicable | +| `error` | Error message string on failure; `null` on success | + +Example with observability enabled (token and URL are illustrative): + + outsystemscc \ + -o \ + --header "token: N2YwMDIxZTEtNGUzNS1jNzgzLTRkYjAtYjE2YzRkZGVmNjcy" \ + https://organization.outsystems.app/sg_6c23a5b4-b718-4634-a503-f22aed17d4e7 \ + R:8081:10.0.0.1:8393 + +Observability lines go to **stdout**; timestamped messages from the default logger go to **stderr**. To keep only JSON lines in a file and still see status messages in the terminal, append stdout: + + outsystemscc \ + -o \ + --header "token: N2YwMDIxZTEtNGUzNS1jNzgzLTRkYjAtYjE2YzRkZGVmNjcy" \ + https://organization.outsystems.app/sg_6c23a5b4-b718-4634-a503-f22aed17d4e7 \ + R:8081:10.0.0.1:8393 \ + >> tunnel_events.jsonl + +To send human-readable logs to a separate file as well: + + outsystemscc \ + -o \ + --header "token: N2YwMDIxZTEtNGUzNS1jNzgzLTRkYjAtYjE2YzRkZGVmNjcy" \ + https://organization.outsystems.app/sg_6c23a5b4-b718-4634-a503-f22aed17d4e7 \ + R:8081:10.0.0.1:8393 \ + >> tunnel_events.jsonl 2>> outsystemscc.log + ## 4. Detailed options [Top ▲](#table-of-contents) @@ -227,6 +282,10 @@ If your organization uses a centralized log management product, see its document --pid Generate pid file in current working directory + -o, Emit JSON events to stdout at key tunnel lifecycle points (starting, + connected, disconnected, error). Each event is a single-line JSON object + including destination hosts, connection status, and latency. + -v, Enable verbose logging --help, This help text diff --git a/main.go b/main.go index fdb10d4..ed1cdde 100644 --- a/main.go +++ b/main.go @@ -64,27 +64,25 @@ func (flag *headerFlags) Set(arg string) error { } type jsonEvent struct { - CorrelationID string `json:"correlation_id"` - Time float64 `json:"time"` - Host string `json:"host"` - Source string `json:"source"` - Sourcetype string `json:"source_type"` - Event tunnelEvent `json:"event"` + CorrelationID string `json:"correlation_id"` + Time float64 `json:"time"` + Host string `json:"host"` + Source string `json:"source"` + Sourcetype string `json:"source_type"` + Event tunnelEvent `json:"event"` } type tunnelEvent struct { - Version string `json:"version"` - EventType string `json:"event_type"` - Server string `json:"server"` - Remotes []string `json:"remotes"` - DestinationHosts []string `json:"destination_hosts"` - Status string `json:"status"` - LatencyMs *int64 `json:"latency_ms"` // null when not yet known - Error *string `json:"error"` // null on success + Version string `json:"version"` + Server string `json:"server"` + Remotes []string `json:"remotes"` + Status string `json:"status"` + LatencyMs *int64 `json:"latency_ms"` // null when not yet known + Error *string `json:"error"` // null on success } -func emitObsEvent(correlationID, eventType, status, server string, remotes []string, - destHosts []string, latencyMs *int64, obsErr *string) { +func emitObsEvent(correlationID, status, server string, remotes []string, + latencyMs *int64, obsErr *string) { hostname, _ := os.Hostname() if hostname == "" { hostname = "unknown" @@ -96,14 +94,12 @@ func emitObsEvent(correlationID, eventType, status, server string, remotes []str Source: "outsystemscc", Sourcetype: "outsystemscc:tunnel", Event: tunnelEvent{ - Version: version, - EventType: eventType, - Server: server, - Remotes: remotes, - DestinationHosts: destHosts, - Status: status, - LatencyMs: latencyMs, - Error: obsErr, + Version: version, + Server: server, + Remotes: remotes, + Status: status, + LatencyMs: latencyMs, + Error: obsErr, }, } data, err := json.Marshal(ev) @@ -114,16 +110,6 @@ func emitObsEvent(correlationID, eventType, status, server string, remotes []str fmt.Println(string(data)) } -func extractDestinationHosts(remotes []string) []string { - hosts := make([]string, 0, len(remotes)) - for _, r := range remotes { - if decoded, err := settings.DecodeRemote(r); err == nil { - hosts = append(hosts, decoded.RemoteHost) - } - } - return hosts -} - var clientHelp = ` Usage: outsystemscc [options] [remote] [remote] ... @@ -228,12 +214,10 @@ func client(args []string) { config.Server = fmt.Sprintf("%s%s", serverURL, queryParams) config.Remotes = args[1:] - var destHosts []string var correlationID string if *observability { - destHosts = extractDestinationHosts(args[1:]) correlationID = uuid.New().String() - emitObsEvent(correlationID, "tunnel_starting", "starting", serverURL, args[1:], destHosts, nil, nil) + emitObsEvent(correlationID, "starting", serverURL, args[1:], nil, nil) } //default auth @@ -260,23 +244,23 @@ func client(args []string) { if err := c.Start(ctx); err != nil { if *observability { errStr := err.Error() - emitObsEvent(correlationID, "tunnel_error", "error", serverURL, args[1:], destHosts, nil, &errStr) + emitObsEvent(correlationID, "error", serverURL, args[1:], nil, &errStr) } log.Fatal(err) } if *observability { ms := time.Since(connectStart).Milliseconds() - emitObsEvent(correlationID, "tunnel_connected", "connected", serverURL, args[1:], destHosts, &ms, nil) + emitObsEvent(correlationID, "connected", serverURL, args[1:], &ms, nil) } if err := c.Wait(); err != nil { if *observability { errStr := err.Error() - emitObsEvent(correlationID, "tunnel_error", "error", serverURL, args[1:], destHosts, nil, &errStr) + emitObsEvent(correlationID, "error", serverURL, args[1:], nil, &errStr) } log.Fatal(err) } if *observability { - emitObsEvent(correlationID, "tunnel_disconnected", "disconnected", serverURL, args[1:], destHosts, nil, nil) + emitObsEvent(correlationID, "disconnected", serverURL, args[1:], nil, nil) } } diff --git a/main_test.go b/main_test.go index 30ef65c..b6b1678 100644 --- a/main_test.go +++ b/main_test.go @@ -15,60 +15,48 @@ import ( func Test_emitObsEvent(t *testing.T) { const testCorrelationID = "550e8400-e29b-41d4-a716-446655440000" tests := []struct { - name string - eventType string - status string - server string - remotes []string - destHosts []string - latencyMs *int64 - obsErr *string - wantEventType string - wantStatus string - wantLatency bool // true = expect non-null latency_ms - wantErr bool // true = expect non-null error + name string + status string + server string + remotes []string + latencyMs *int64 + obsErr *string + wantStatus string + wantLatency bool // true = expect non-null latency_ms + wantErr bool // true = expect non-null error }{ { - name: "tunnel_starting no latency no error", - eventType: "tunnel_starting", - status: "starting", - server: "wss://pg.example.com", - remotes: []string{"R:8081:db.internal:5432"}, - destHosts: []string{"db.internal"}, - latencyMs: nil, - obsErr: nil, - wantEventType: "tunnel_starting", - wantStatus: "starting", - wantLatency: false, - wantErr: false, + name: "starting no latency no error", + status: "starting", + server: "wss://pg.example.com", + remotes: []string{"R:8081:db.internal:5432"}, + latencyMs: nil, + obsErr: nil, + wantStatus: "starting", + wantLatency: false, + wantErr: false, }, { - name: "tunnel_connected with latency", - eventType: "tunnel_connected", - status: "connected", - server: "wss://pg.example.com", - remotes: []string{"R:8081:db.internal:5432"}, - destHosts: []string{"db.internal"}, - latencyMs: func() *int64 { v := int64(266); return &v }(), - obsErr: nil, - wantEventType: "tunnel_connected", - wantStatus: "connected", - wantLatency: true, - wantErr: false, + name: "connected with latency", + status: "connected", + server: "wss://pg.example.com", + remotes: []string{"R:8081:db.internal:5432"}, + latencyMs: func() *int64 { v := int64(266); return &v }(), + obsErr: nil, + wantStatus: "connected", + wantLatency: true, + wantErr: false, }, { - name: "tunnel_error with error string", - eventType: "tunnel_error", - status: "error", - server: "wss://pg.example.com", - remotes: []string{"R:8081:db.internal:5432"}, - destHosts: []string{"db.internal"}, - latencyMs: nil, - obsErr: func() *string { s := "connection refused"; return &s }(), - wantEventType: "tunnel_error", - wantStatus: "error", - wantLatency: false, - wantErr: true, + name: "error with error string", + status: "error", + server: "wss://pg.example.com", + remotes: []string{"R:8081:db.internal:5432"}, + latencyMs: nil, + obsErr: func() *string { s := "connection refused"; return &s }(), + wantStatus: "error", + wantLatency: false, + wantErr: true, }, } @@ -83,7 +71,7 @@ func Test_emitObsEvent(t *testing.T) { os.Stdout = w // Act - emitObsEvent(testCorrelationID, tt.eventType, tt.status, tt.server, tt.remotes, tt.destHosts, tt.latencyMs, tt.obsErr) + emitObsEvent(testCorrelationID, tt.status, tt.server, tt.remotes, tt.latencyMs, tt.obsErr) // Restore stdout and read output w.Close() @@ -111,9 +99,6 @@ func Test_emitObsEvent(t *testing.T) { if ev.CorrelationID != testCorrelationID { t.Errorf("correlation_id = %q, want %q", ev.CorrelationID, testCorrelationID) } - if ev.Event.EventType != tt.wantEventType { - t.Errorf("event_type = %q, want %q", ev.Event.EventType, tt.wantEventType) - } if ev.Event.Status != tt.wantStatus { t.Errorf("status = %q, want %q", ev.Event.Status, tt.wantStatus) } @@ -133,58 +118,6 @@ func Test_emitObsEvent(t *testing.T) { } } -func Test_extractDestinationHosts(t *testing.T) { - tests := []struct { - name string - remotes []string - want []string - }{ - { - name: "single valid remote", - remotes: []string{"R:8081:db.internal:5432"}, - want: []string{"db.internal"}, - }, - { - name: "multiple valid remotes", - remotes: []string{"R:8081:db.internal:5432", "R:8082:cache.internal:6379"}, - want: []string{"db.internal", "cache.internal"}, - }, - { - name: "invalid remote is skipped", - remotes: []string{"not-a-valid-remote"}, - want: []string{}, - }, - { - name: "mix of valid and invalid remotes", - remotes: []string{"R:8081:db.internal:5432", "not-valid"}, - want: []string{"db.internal"}, - }, - { - name: "empty remotes", - remotes: []string{}, - want: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Act - got := extractDestinationHosts(tt.remotes) - - // Assert - if len(got) != len(tt.want) { - t.Errorf("extractDestinationHosts() len = %d, want %d; got %v", len(got), len(tt.want), got) - return - } - for i, h := range got { - if h != tt.want[i] { - t.Errorf("extractDestinationHosts()[%d] = %q, want %q", i, h, tt.want[i]) - } - } - }) - } -} - func Test_validateRemotes(t *testing.T) { tests := []struct { From e705fe5de8b1f85583733276563d2f0068ef3084 Mon Sep 17 00:00:00 2001 From: "Diogo Paulo (dpa)" Date: Tue, 31 Mar 2026 13:59:55 +0100 Subject: [PATCH 3/4] Changed the time scale to arbitrary from milliseconds. The latency property specifies what time unit it uses in string format. The event time now is NanoSeconds. --- README.md | 4 ++-- main.go | 12 ++++++------ main_test.go | 23 +++++++++++++---------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c30b3cc..037a467 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ Top-level fields on every line: | Field | Meaning | | --- | --- | | `correlation_id` | UUID for this connector run | -| `time` | Unix time in seconds with fractional part | +| `time` | Unix time in nanoseconds | | `host` | Hostname of the machine running `outsystemscc` | | `source` | Always `outsystemscc` | | `source_type` | Always `outsystemscc:tunnel` | @@ -204,7 +204,7 @@ The nested **`event`** object contains: | `status` | Lifecycle state: `starting`, `connected`, `disconnected`, or `error` | | `server` | Resolved Private Gateway server URL used for the tunnel | | `remotes` | Remote specs as passed on the command line (e.g. `R:8081:10.0.0.1:8393`) | -| `latency_ms` | Round-trip time in ms when connected; `null` if not applicable | +| `latency` | Round-trip time when connected; `null` if not applicable | | `error` | Error message string on failure; `null` on success | Example with observability enabled (token and URL are illustrative): diff --git a/main.go b/main.go index ed1cdde..f638449 100644 --- a/main.go +++ b/main.go @@ -65,7 +65,7 @@ func (flag *headerFlags) Set(arg string) error { type jsonEvent struct { CorrelationID string `json:"correlation_id"` - Time float64 `json:"time"` + Time int64 `json:"time"` Host string `json:"host"` Source string `json:"source"` Sourcetype string `json:"source_type"` @@ -77,19 +77,19 @@ type tunnelEvent struct { Server string `json:"server"` Remotes []string `json:"remotes"` Status string `json:"status"` - LatencyMs *int64 `json:"latency_ms"` // null when not yet known + Latency *string `json:"latency"` // null when not yet known Error *string `json:"error"` // null on success } func emitObsEvent(correlationID, status, server string, remotes []string, - latencyMs *int64, obsErr *string) { + latency *string, obsErr *string) { hostname, _ := os.Hostname() if hostname == "" { hostname = "unknown" } ev := jsonEvent{ CorrelationID: correlationID, - Time: float64(time.Now().UnixMilli()) / 1000.0, + Time: time.Now().UnixNano(), Host: hostname, Source: "outsystemscc", Sourcetype: "outsystemscc:tunnel", @@ -98,7 +98,7 @@ func emitObsEvent(correlationID, status, server string, remotes []string, Server: server, Remotes: remotes, Status: status, - LatencyMs: latencyMs, + Latency: latency, Error: obsErr, }, } @@ -249,7 +249,7 @@ func client(args []string) { log.Fatal(err) } if *observability { - ms := time.Since(connectStart).Milliseconds() + ms := time.Since(connectStart).String() emitObsEvent(correlationID, "connected", serverURL, args[1:], &ms, nil) } if err := c.Wait(); err != nil { diff --git a/main_test.go b/main_test.go index b6b1678..88d2780 100644 --- a/main_test.go +++ b/main_test.go @@ -19,10 +19,10 @@ func Test_emitObsEvent(t *testing.T) { status string server string remotes []string - latencyMs *int64 + latency *string obsErr *string wantStatus string - wantLatency bool // true = expect non-null latency_ms + wantLatency bool // true = expect non-null latency (JSON key "latency") wantErr bool // true = expect non-null error }{ { @@ -30,7 +30,7 @@ func Test_emitObsEvent(t *testing.T) { status: "starting", server: "wss://pg.example.com", remotes: []string{"R:8081:db.internal:5432"}, - latencyMs: nil, + latency: nil, obsErr: nil, wantStatus: "starting", wantLatency: false, @@ -41,7 +41,7 @@ func Test_emitObsEvent(t *testing.T) { status: "connected", server: "wss://pg.example.com", remotes: []string{"R:8081:db.internal:5432"}, - latencyMs: func() *int64 { v := int64(266); return &v }(), + latency: func() *string { s := "266ms"; return &s }(), obsErr: nil, wantStatus: "connected", wantLatency: true, @@ -52,7 +52,7 @@ func Test_emitObsEvent(t *testing.T) { status: "error", server: "wss://pg.example.com", remotes: []string{"R:8081:db.internal:5432"}, - latencyMs: nil, + latency: nil, obsErr: func() *string { s := "connection refused"; return &s }(), wantStatus: "error", wantLatency: false, @@ -71,7 +71,7 @@ func Test_emitObsEvent(t *testing.T) { os.Stdout = w // Act - emitObsEvent(testCorrelationID, tt.status, tt.server, tt.remotes, tt.latencyMs, tt.obsErr) + emitObsEvent(testCorrelationID, tt.status, tt.server, tt.remotes, tt.latency, tt.obsErr) // Restore stdout and read output w.Close() @@ -102,11 +102,14 @@ func Test_emitObsEvent(t *testing.T) { if ev.Event.Status != tt.wantStatus { t.Errorf("status = %q, want %q", ev.Event.Status, tt.wantStatus) } - if tt.wantLatency && ev.Event.LatencyMs == nil { - t.Errorf("latency_ms is nil, want non-nil") + if tt.wantLatency && ev.Event.Latency == nil { + t.Errorf("latency is nil, want non-nil") } - if !tt.wantLatency && ev.Event.LatencyMs != nil { - t.Errorf("latency_ms = %v, want nil", *ev.Event.LatencyMs) + if !tt.wantLatency && ev.Event.Latency != nil { + t.Errorf("latency = %q, want nil", *ev.Event.Latency) + } + if tt.latency != nil && ev.Event.Latency != nil && *ev.Event.Latency != *tt.latency { + t.Errorf("latency = %q, want %q", *ev.Event.Latency, *tt.latency) } if tt.wantErr && ev.Event.Error == nil { t.Errorf("error is nil, want non-nil") From 67d560b727204913ded3877d0e1cb73b1f06ae4a Mon Sep 17 00:00:00 2001 From: "Diogo Paulo (dpa)" Date: Mon, 6 Apr 2026 13:59:26 +0100 Subject: [PATCH 4/4] Changed the time to milli --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index f638449..22eb21b 100644 --- a/main.go +++ b/main.go @@ -89,7 +89,7 @@ func emitObsEvent(correlationID, status, server string, remotes []string, } ev := jsonEvent{ CorrelationID: correlationID, - Time: time.Now().UnixNano(), + Time: time.Now().UnixMilli(), Host: hostname, Source: "outsystemscc", Sourcetype: "outsystemscc:tunnel",