Skip to content
Open
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
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.

### <a name="observability"></a> 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 nanoseconds |
| `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` | 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):

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. <a name="detailed-options"></a> Detailed options <small><sup>[Top ▲](#table-of-contents)</sup></small>


Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
76 changes: 76 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"flag"
"fmt"
"log"
Expand All @@ -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"
Expand Down Expand Up @@ -61,6 +63,53 @@ func (flag *headerFlags) Set(arg string) error {
return nil
}

type jsonEvent struct {
CorrelationID string `json:"correlation_id"`
Time int64 `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"`
Server string `json:"server"`
Remotes []string `json:"remotes"`
Status string `json:"status"`
Latency *string `json:"latency"` // null when not yet known
Error *string `json:"error"` // null on success
}

func emitObsEvent(correlationID, status, server string, remotes []string,
latency *string, obsErr *string) {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
ev := jsonEvent{
CorrelationID: correlationID,
Time: time.Now().UnixMilli(),
Host: hostname,
Source: "outsystemscc",
Sourcetype: "outsystemscc:tunnel",
Event: tunnelEvent{
Version: version,
Server: server,
Remotes: remotes,
Status: status,
Latency: latency,
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))
}

var clientHelp = `
Usage: outsystemscc [options] <server> <remote> [remote] [remote] ...

Expand Down Expand Up @@ -106,6 +155,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
Expand All @@ -130,6 +183,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)
Expand Down Expand Up @@ -160,6 +214,12 @@ func client(args []string) {
config.Server = fmt.Sprintf("%s%s", serverURL, queryParams)
config.Remotes = args[1:]

var correlationID string
if *observability {
correlationID = uuid.New().String()
emitObsEvent(correlationID, "starting", serverURL, args[1:], nil, nil)
}

//default auth
if config.Auth == "" {
config.Auth = os.Getenv("AUTH")
Expand All @@ -180,12 +240,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, "error", serverURL, args[1:], nil, &errStr)
}
log.Fatal(err)
}
if *observability {
ms := time.Since(connectStart).String()
emitObsEvent(correlationID, "connected", serverURL, args[1:], &ms, nil)
}
if err := c.Wait(); err != nil {
if *observability {
errStr := err.Error()
emitObsEvent(correlationID, "error", serverURL, args[1:], nil, &errStr)
}
log.Fatal(err)
}
if *observability {
emitObsEvent(correlationID, "disconnected", serverURL, args[1:], nil, nil)
}
}

func createHTTPClient(config *chclient.Config) *resty.Client {
Expand Down
111 changes: 111 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package main

import (
"encoding/json"
"net/http"
"os"
"testing"

"strings"
Expand All @@ -10,6 +12,115 @@ import (
"github.com/jarcoal/httpmock"
)

func Test_emitObsEvent(t *testing.T) {
const testCorrelationID = "550e8400-e29b-41d4-a716-446655440000"
tests := []struct {
name string
status string
server string
remotes []string
latency *string
obsErr *string
wantStatus string
wantLatency bool // true = expect non-null latency (JSON key "latency")
wantErr bool // true = expect non-null error
}{
{
name: "starting no latency no error",
status: "starting",
server: "wss://pg.example.com",
remotes: []string{"R:8081:db.internal:5432"},
latency: nil,
obsErr: nil,
wantStatus: "starting",
wantLatency: false,
wantErr: false,
},
{
name: "connected with latency",
status: "connected",
server: "wss://pg.example.com",
remotes: []string{"R:8081:db.internal:5432"},
latency: func() *string { s := "266ms"; return &s }(),
obsErr: nil,
wantStatus: "connected",
wantLatency: true,
wantErr: false,
},
{
name: "error with error string",
status: "error",
server: "wss://pg.example.com",
remotes: []string{"R:8081:db.internal:5432"},
latency: nil,
obsErr: func() *string { s := "connection refused"; return &s }(),
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.status, tt.server, tt.remotes, tt.latency, 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.Status != tt.wantStatus {
t.Errorf("status = %q, want %q", ev.Event.Status, tt.wantStatus)
}
if tt.wantLatency && ev.Event.Latency == nil {
t.Errorf("latency is nil, want non-nil")
}
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")
}
if !tt.wantErr && ev.Event.Error != nil {
t.Errorf("error = %q, want nil", *ev.Event.Error)
}
})
}
}

func Test_validateRemotes(t *testing.T) {

tests := []struct {
Expand Down
Loading