Skip to content

Commit 69b3106

Browse files
committed
feat(metrics): add per-IP and DeFi transaction content metrics
Signed-off-by: Ales Verbic <verbotenj@blinklabs.io>
1 parent f854315 commit 69b3106

File tree

8 files changed

+640
-28
lines changed

8 files changed

+640
-28
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ require (
3636
github.com/jinzhu/copier v0.4.0 // indirect
3737
github.com/josharian/intern v1.0.0 // indirect
3838
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
39+
github.com/kylelemons/godebug v1.1.0 // indirect
3940
github.com/mailru/easyjson v0.7.7 // indirect
4041
github.com/minio/sha256-simd v1.0.1 // indirect
4142
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect

internal/api/api.go

Lines changed: 79 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import (
2424
"io"
2525
"io/fs"
2626
"mime"
27+
"net"
2728
"net/http"
2829
"runtime/debug"
30+
"strings"
2931
"time"
3032

3133
ouroboros "github.com/blinklabs-io/gouroboros"
@@ -34,8 +36,8 @@ import (
3436
_ "github.com/blinklabs-io/tx-submit-api/docs" // docs is generated by Swag CLI
3537
"github.com/blinklabs-io/tx-submit-api/internal/config"
3638
"github.com/blinklabs-io/tx-submit-api/internal/logging"
39+
"github.com/blinklabs-io/tx-submit-api/internal/metrics"
3740
"github.com/blinklabs-io/tx-submit-api/submit"
38-
"github.com/prometheus/client_golang/prometheus"
3941
"github.com/prometheus/client_golang/prometheus/promhttp"
4042
httpSwagger "github.com/swaggo/http-swagger"
4143
)
@@ -47,23 +49,6 @@ var staticFS embed.FS
4749
// submission. Cardano's protocol limit is ~16KB; 64KB gives ample overhead.
4850
const maxTxBodyBytes = 64 * 1024
4951

50-
var (
51-
// Gauge to match input-output-hk's metric type
52-
txSubmitFailCount = prometheus.NewGauge(prometheus.GaugeOpts{
53-
Name: "tx_submit_fail_count",
54-
Help: "transactions failed",
55-
})
56-
// Gauge to match input-output-hk's metric type
57-
txSubmitCount = prometheus.NewGauge(prometheus.GaugeOpts{
58-
Name: "tx_submit_count",
59-
Help: "transactions submitted",
60-
})
61-
)
62-
63-
func init() {
64-
prometheus.MustRegister(txSubmitFailCount, txSubmitCount)
65-
}
66-
6752
// corsMiddleware adds CORS headers allowing all origins.
6853
func corsMiddleware(next http.Handler) http.Handler {
6954
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -228,6 +213,8 @@ func Start(cfg *config.Config) error {
228213
handler = recoveryMiddleware(handler)
229214
handler = corsMiddleware(handler)
230215

216+
metrics.Register()
217+
231218
// Start metrics listener
232219
go func() {
233220
logger.Info("starting metrics listener",
@@ -352,16 +339,17 @@ func handleHasTx(w http.ResponseWriter, r *http.Request) {
352339
// @Failure 500 {object} string "Server Error"
353340
// @Router /api/submit/tx [post]
354341
func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
355-
// First, initialize our configuration and loggers
356342
cfg := config.GetConfig()
357343
logger := logging.GetLogger()
344+
clientIP := realClientIP(r, cfg.Api.TrustedProxies)
358345

359-
// Check our headers for content-type
346+
// Check our headers for content-type. Wrong content-type is rejected before
347+
// reading the body, so no IP metric is recorded here.
360348
mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
361349
if mediaType != "application/cbor" {
362350
logger.Error("invalid request body, should be application/cbor")
363351
writeJSON(w, http.StatusUnsupportedMediaType, "invalid request body, should be application/cbor")
364-
txSubmitFailCount.Inc()
352+
metrics.IncTxSubmitFailCount()
365353
return
366354
}
367355

@@ -376,9 +364,18 @@ func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
376364
logger.Error("failed to read request body", "err", err)
377365
writeJSON(w, http.StatusInternalServerError, "failed to read request body")
378366
}
379-
txSubmitFailCount.Inc()
367+
metrics.IncTxSubmitFailCount()
368+
metrics.RecordTxRequest(clientIP, "error")
380369
return
381370
}
371+
372+
// Parse tx content signals before submitting — best-effort, never blocks submission.
373+
txInfo, err := submit.ParseTxInfo(txRawBytes)
374+
if err != nil {
375+
logger.Warn("failed to parse tx content signals", "err", err, "ip", clientIP)
376+
txInfo = nil
377+
}
378+
382379
// Send TX
383380
errorChan := make(chan error, 1)
384381
submitConfig := &submit.Config{
@@ -391,9 +388,10 @@ func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
391388
}
392389
txHash, err := submit.SubmitTx(submitConfig, txRawBytes)
393390
if err != nil {
391+
var txRejectErr *localtxsubmission.TransactionRejectedError
392+
isRejected := errors.As(err, &txRejectErr)
394393
if r.Header.Get("Accept") == "application/cbor" {
395-
var txRejectErr *localtxsubmission.TransactionRejectedError
396-
if errors.As(err, &txRejectErr) {
394+
if isRejected && txRejectErr != nil {
397395
w.Header().Set("Content-Type", "application/cbor")
398396
w.WriteHeader(http.StatusBadRequest)
399397
_, _ = w.Write(txRejectErr.ReasonCbor)
@@ -405,13 +403,25 @@ func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
405403
} else {
406404
writeJSON(w, http.StatusBadRequest, err.Error())
407405
}
408-
txSubmitFailCount.Inc()
406+
result := "error"
407+
if isRejected {
408+
result = "rejected"
409+
}
410+
metrics.IncTxSubmitFailCount()
411+
metrics.RecordTxRequest(clientIP, result)
412+
if txInfo != nil {
413+
metrics.RecordTxContent(txInfo.ScriptType, txInfo.HasMinting, txInfo.HasReferenceInputs)
414+
}
409415
return
410416
}
411417

412418
// Node confirmed the tx is in its mempool (AcceptTx received synchronously).
413419
// Record success before responding so metrics always agree with the HTTP status.
414-
txSubmitCount.Inc()
420+
metrics.IncTxSubmitCount()
421+
metrics.RecordTxRequest(clientIP, "accepted")
422+
if txInfo != nil {
423+
metrics.RecordTxContent(txInfo.ScriptType, txInfo.HasMinting, txInfo.HasReferenceInputs)
424+
}
415425
writeJSON(w, http.StatusAccepted, txHash)
416426

417427
// Drain errorChan in the background. Post-submission connection errors do not
@@ -427,3 +437,46 @@ func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
427437
}
428438
}()
429439
}
440+
441+
// realClientIP extracts the client IP from the request. Forwarded headers
442+
// (X-Real-IP, X-Forwarded-For) are only trusted when the immediate peer
443+
// (r.RemoteAddr) is in the trustedProxies list; otherwise RemoteAddr is used
444+
// directly to prevent IP spoofing by untrusted callers.
445+
func realClientIP(r *http.Request, trustedProxies []string) string {
446+
// Parse the immediate peer IP from RemoteAddr.
447+
peerHost, _, err := net.SplitHostPort(r.RemoteAddr)
448+
if err != nil {
449+
peerHost = r.RemoteAddr
450+
}
451+
peerIP := net.ParseIP(peerHost)
452+
453+
// Only honour forwarded headers when the peer is a trusted proxy.
454+
if peerIP != nil {
455+
for _, cidr := range trustedProxies {
456+
var trusted net.IP
457+
var network *net.IPNet
458+
if strings.Contains(cidr, "/") {
459+
_, network, err = net.ParseCIDR(cidr)
460+
if err != nil {
461+
continue
462+
}
463+
} else {
464+
trusted = net.ParseIP(cidr)
465+
}
466+
inRange := (network != nil && network.Contains(peerIP)) ||
467+
(trusted != nil && trusted.Equal(peerIP))
468+
if inRange {
469+
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" {
470+
return ip
471+
}
472+
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
473+
first, _, _ := strings.Cut(forwarded, ",")
474+
return strings.TrimSpace(first)
475+
}
476+
break
477+
}
478+
}
479+
}
480+
481+
return peerHost
482+
}

internal/api/api_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@ import (
2525

2626
"github.com/blinklabs-io/tx-submit-api/internal/config"
2727
"github.com/blinklabs-io/tx-submit-api/internal/logging"
28+
"github.com/blinklabs-io/tx-submit-api/internal/metrics"
29+
"github.com/prometheus/client_golang/prometheus/testutil"
2830
)
2931

3032
func TestMain(m *testing.M) {
3133
logging.Setup(&config.LoggingConfig{Level: "error"})
34+
metrics.RegisterForTesting()
3235
os.Exit(m.Run())
3336
}
3437

@@ -42,6 +45,90 @@ func newTestMux() http.Handler {
4245
return handler
4346
}
4447

48+
// --- realClientIP ---
49+
50+
func TestRealClientIP(t *testing.T) {
51+
t.Parallel()
52+
tests := []struct {
53+
name string
54+
xRealIP string
55+
xForwarded string
56+
remoteAddr string
57+
trustedProxies []string
58+
want string
59+
}{
60+
{
61+
name: "X-Real-IP wins when peer is trusted",
62+
xRealIP: "1.2.3.4",
63+
xForwarded: "5.6.7.8",
64+
remoteAddr: "9.10.11.12:9000",
65+
trustedProxies: []string{"9.10.11.12"},
66+
want: "1.2.3.4",
67+
},
68+
{
69+
name: "X-Forwarded-For single IP when peer is trusted",
70+
xForwarded: "10.0.0.1",
71+
remoteAddr: "9.10.11.12:9000",
72+
trustedProxies: []string{"9.10.11.12"},
73+
want: "10.0.0.1",
74+
},
75+
{
76+
name: "X-Forwarded-For multiple IPs returns first when peer is trusted",
77+
xForwarded: "10.0.0.1, 172.16.0.1, 192.168.1.1",
78+
remoteAddr: "9.10.11.12:9000",
79+
trustedProxies: []string{"9.10.11.12"},
80+
want: "10.0.0.1",
81+
},
82+
{
83+
name: "forwarded headers ignored when peer is not trusted",
84+
xRealIP: "1.2.3.4",
85+
xForwarded: "5.6.7.8",
86+
remoteAddr: "9.10.11.12:9000",
87+
trustedProxies: []string{},
88+
want: "9.10.11.12",
89+
},
90+
{
91+
name: "trusted proxy matched by CIDR",
92+
xRealIP: "1.2.3.4",
93+
remoteAddr: "10.0.0.5:9000",
94+
trustedProxies: []string{"10.0.0.0/8"},
95+
want: "1.2.3.4",
96+
},
97+
{
98+
name: "RemoteAddr with port strips port",
99+
remoteAddr: "203.0.113.5:54321",
100+
want: "203.0.113.5",
101+
},
102+
{
103+
name: "RemoteAddr without port returned as-is",
104+
remoteAddr: "203.0.113.5",
105+
want: "203.0.113.5",
106+
},
107+
{
108+
name: "IPv6 RemoteAddr strips port",
109+
remoteAddr: "[::1]:8080",
110+
want: "::1",
111+
},
112+
}
113+
114+
for _, tt := range tests {
115+
t.Run(tt.name, func(t *testing.T) {
116+
t.Parallel()
117+
req := httptest.NewRequest(http.MethodGet, "/", nil)
118+
req.RemoteAddr = tt.remoteAddr
119+
if tt.xRealIP != "" {
120+
req.Header.Set("X-Real-IP", tt.xRealIP)
121+
}
122+
if tt.xForwarded != "" {
123+
req.Header.Set("X-Forwarded-For", tt.xForwarded)
124+
}
125+
if got := realClientIP(req, tt.trustedProxies); got != tt.want {
126+
t.Errorf("want %q, got %q", tt.want, got)
127+
}
128+
})
129+
}
130+
}
131+
45132
// --- healthcheck ---
46133

47134
func TestHealthcheck_OK(t *testing.T) {
@@ -182,3 +269,43 @@ func TestCORS(t *testing.T) {
182269
})
183270
}
184271
}
272+
273+
// --- metrics ---
274+
275+
func TestSubmitTx_RequestsTotal_InvalidCBOR(t *testing.T) {
276+
// Not parallel: reads counter value which is package-global state.
277+
before := testutil.ToFloat64(metrics.TxSubmitRequestsTotal().WithLabelValues("1.2.3.4", "error"))
278+
279+
rec := httptest.NewRecorder()
280+
req := httptest.NewRequest(http.MethodPost, "/api/submit/tx", strings.NewReader("not-valid-cbor"))
281+
req.Header.Set("Content-Type", "application/cbor")
282+
req.Header.Set("X-Real-IP", "1.2.3.4")
283+
newTestMux().ServeHTTP(rec, req)
284+
285+
if rec.Code != http.StatusBadRequest {
286+
t.Fatalf("expected 400, got %d", rec.Code)
287+
}
288+
after := testutil.ToFloat64(metrics.TxSubmitRequestsTotal().WithLabelValues("1.2.3.4", "error"))
289+
if after-before != 1 {
290+
t.Errorf("requests_total{ip=1.2.3.4,result=error}: expected increment of 1, got %f", after-before)
291+
}
292+
}
293+
294+
func TestSubmitTx_RequestsTotal_NoIPMetricOnBadContentType(t *testing.T) {
295+
// Not parallel: reads counter value which is package-global state.
296+
before := testutil.ToFloat64(metrics.TxSubmitRequestsTotal().WithLabelValues("2.3.4.5", "error"))
297+
298+
rec := httptest.NewRecorder()
299+
req := httptest.NewRequest(http.MethodPost, "/api/submit/tx", strings.NewReader("data"))
300+
req.Header.Set("Content-Type", "application/json")
301+
req.Header.Set("X-Real-IP", "2.3.4.5")
302+
newTestMux().ServeHTTP(rec, req)
303+
304+
if rec.Code != http.StatusUnsupportedMediaType {
305+
t.Fatalf("expected 415, got %d", rec.Code)
306+
}
307+
after := testutil.ToFloat64(metrics.TxSubmitRequestsTotal().WithLabelValues("2.3.4.5", "error"))
308+
if after != before {
309+
t.Errorf("requests_total should not increment on content-type rejection, got increment of %f", after-before)
310+
}
311+
}

internal/config/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ type LoggingConfig struct {
3939
}
4040

4141
type ApiConfig struct {
42-
ListenAddress string `yaml:"address" envconfig:"API_LISTEN_ADDRESS"`
43-
ListenPort uint `yaml:"port" envconfig:"API_LISTEN_PORT"`
42+
ListenAddress string `yaml:"address" envconfig:"API_LISTEN_ADDRESS"`
43+
ListenPort uint `yaml:"port" envconfig:"API_LISTEN_PORT"`
44+
TrustedProxies []string `yaml:"trustedProxies" envconfig:"API_TRUSTED_PROXIES"`
4445
}
4546

4647
type DebugConfig struct {

0 commit comments

Comments
 (0)