Skip to content

Commit e11fb0a

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 e11fb0a

File tree

7 files changed

+585
-26
lines changed

7 files changed

+585
-26
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: 52 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)
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,21 @@ 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")
369+
return
370+
}
371+
372+
// Parse tx content signals before submitting so we can record them regardless
373+
// of whether the node accepts or rejects the transaction.
374+
txInfo, err := submit.ParseTxInfo(txRawBytes)
375+
if err != nil {
376+
writeJSON(w, http.StatusBadRequest, err.Error())
377+
metrics.IncTxSubmitFailCount()
378+
metrics.RecordTxRequest(clientIP, "error")
380379
return
381380
}
381+
382382
// Send TX
383383
errorChan := make(chan error, 1)
384384
submitConfig := &submit.Config{
@@ -391,9 +391,10 @@ func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
391391
}
392392
txHash, err := submit.SubmitTx(submitConfig, txRawBytes)
393393
if err != nil {
394+
var txRejectErr *localtxsubmission.TransactionRejectedError
395+
isRejected := errors.As(err, &txRejectErr)
394396
if r.Header.Get("Accept") == "application/cbor" {
395-
var txRejectErr *localtxsubmission.TransactionRejectedError
396-
if errors.As(err, &txRejectErr) {
397+
if isRejected && txRejectErr != nil {
397398
w.Header().Set("Content-Type", "application/cbor")
398399
w.WriteHeader(http.StatusBadRequest)
399400
_, _ = w.Write(txRejectErr.ReasonCbor)
@@ -405,13 +406,21 @@ func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
405406
} else {
406407
writeJSON(w, http.StatusBadRequest, err.Error())
407408
}
408-
txSubmitFailCount.Inc()
409+
result := "error"
410+
if isRejected {
411+
result = "rejected"
412+
}
413+
metrics.IncTxSubmitFailCount()
414+
metrics.RecordTxRequest(clientIP, result)
415+
metrics.RecordTxContent(txInfo.ScriptType, txInfo.HasMinting, txInfo.HasReferenceInputs)
409416
return
410417
}
411418

412419
// Node confirmed the tx is in its mempool (AcceptTx received synchronously).
413420
// Record success before responding so metrics always agree with the HTTP status.
414-
txSubmitCount.Inc()
421+
metrics.IncTxSubmitCount()
422+
metrics.RecordTxRequest(clientIP, "accepted")
423+
metrics.RecordTxContent(txInfo.ScriptType, txInfo.HasMinting, txInfo.HasReferenceInputs)
415424
writeJSON(w, http.StatusAccepted, txHash)
416425

417426
// Drain errorChan in the background. Post-submission connection errors do not
@@ -427,3 +436,20 @@ func handleSubmitTx(w http.ResponseWriter, r *http.Request) {
427436
}
428437
}()
429438
}
439+
440+
// realClientIP extracts the real client IP from the request, accounting for
441+
// reverse proxies. Priority: X-Real-IP > first entry of X-Forwarded-For > RemoteAddr.
442+
func realClientIP(r *http.Request) string {
443+
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" {
444+
return ip
445+
}
446+
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
447+
first, _, _ := strings.Cut(forwarded, ",")
448+
return strings.TrimSpace(first)
449+
}
450+
host, _, err := net.SplitHostPort(r.RemoteAddr)
451+
if err != nil {
452+
return r.RemoteAddr
453+
}
454+
return host
455+
}

internal/api/api_test.go

Lines changed: 108 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,71 @@ 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+
want string
58+
}{
59+
{
60+
name: "X-Real-IP wins",
61+
xRealIP: "1.2.3.4",
62+
xForwarded: "5.6.7.8",
63+
remoteAddr: "9.10.11.12:9000",
64+
want: "1.2.3.4",
65+
},
66+
{
67+
name: "X-Forwarded-For single IP",
68+
xForwarded: "10.0.0.1",
69+
remoteAddr: "9.10.11.12:9000",
70+
want: "10.0.0.1",
71+
},
72+
{
73+
name: "X-Forwarded-For multiple IPs returns first",
74+
xForwarded: "10.0.0.1, 172.16.0.1, 192.168.1.1",
75+
remoteAddr: "9.10.11.12:9000",
76+
want: "10.0.0.1",
77+
},
78+
{
79+
name: "RemoteAddr with port strips port",
80+
remoteAddr: "203.0.113.5:54321",
81+
want: "203.0.113.5",
82+
},
83+
{
84+
name: "RemoteAddr without port returned as-is",
85+
remoteAddr: "203.0.113.5",
86+
want: "203.0.113.5",
87+
},
88+
{
89+
name: "IPv6 RemoteAddr strips port",
90+
remoteAddr: "[::1]:8080",
91+
want: "::1",
92+
},
93+
}
94+
95+
for _, tt := range tests {
96+
t.Run(tt.name, func(t *testing.T) {
97+
t.Parallel()
98+
req := httptest.NewRequest(http.MethodGet, "/", nil)
99+
req.RemoteAddr = tt.remoteAddr
100+
if tt.xRealIP != "" {
101+
req.Header.Set("X-Real-IP", tt.xRealIP)
102+
}
103+
if tt.xForwarded != "" {
104+
req.Header.Set("X-Forwarded-For", tt.xForwarded)
105+
}
106+
if got := realClientIP(req); got != tt.want {
107+
t.Errorf("want %q, got %q", tt.want, got)
108+
}
109+
})
110+
}
111+
}
112+
45113
// --- healthcheck ---
46114

47115
func TestHealthcheck_OK(t *testing.T) {
@@ -182,3 +250,43 @@ func TestCORS(t *testing.T) {
182250
})
183251
}
184252
}
253+
254+
// --- metrics ---
255+
256+
func TestSubmitTx_RequestsTotal_InvalidCBOR(t *testing.T) {
257+
// Not parallel: reads counter value which is package-global state.
258+
before := testutil.ToFloat64(metrics.TxSubmitRequestsTotal().WithLabelValues("1.2.3.4", "error"))
259+
260+
rec := httptest.NewRecorder()
261+
req := httptest.NewRequest(http.MethodPost, "/api/submit/tx", strings.NewReader("not-valid-cbor"))
262+
req.Header.Set("Content-Type", "application/cbor")
263+
req.Header.Set("X-Real-IP", "1.2.3.4")
264+
newTestMux().ServeHTTP(rec, req)
265+
266+
if rec.Code != http.StatusBadRequest {
267+
t.Fatalf("expected 400, got %d", rec.Code)
268+
}
269+
after := testutil.ToFloat64(metrics.TxSubmitRequestsTotal().WithLabelValues("1.2.3.4", "error"))
270+
if after-before != 1 {
271+
t.Errorf("requests_total{ip=1.2.3.4,result=error}: expected increment of 1, got %f", after-before)
272+
}
273+
}
274+
275+
func TestSubmitTx_RequestsTotal_NoIPMetricOnBadContentType(t *testing.T) {
276+
// Not parallel: reads counter value which is package-global state.
277+
before := testutil.ToFloat64(metrics.TxSubmitRequestsTotal().WithLabelValues("2.3.4.5", "error"))
278+
279+
rec := httptest.NewRecorder()
280+
req := httptest.NewRequest(http.MethodPost, "/api/submit/tx", strings.NewReader("data"))
281+
req.Header.Set("Content-Type", "application/json")
282+
req.Header.Set("X-Real-IP", "2.3.4.5")
283+
newTestMux().ServeHTTP(rec, req)
284+
285+
if rec.Code != http.StatusUnsupportedMediaType {
286+
t.Fatalf("expected 415, got %d", rec.Code)
287+
}
288+
after := testutil.ToFloat64(metrics.TxSubmitRequestsTotal().WithLabelValues("2.3.4.5", "error"))
289+
if after != before {
290+
t.Errorf("requests_total should not increment on content-type rejection, got increment of %f", after-before)
291+
}
292+
}

0 commit comments

Comments
 (0)