WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit d74d5dc

Browse files
authored
Merge pull request #106 from vimeo/dials_stacking_status_page
Add status page to enable source-stacking debugging/observability
2 parents 6094c76 + 514e181 commit d74d5dc

File tree

6 files changed

+224
-56
lines changed

6 files changed

+224
-56
lines changed

.github/workflows/go.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jobs:
77
strategy:
88
matrix:
99
os: [macos-latest, ubuntu-latest]
10-
goversion: ["1.23", "1.24", "1.25"]
10+
goversion: ["1.24", "1.25"]
1111
steps:
1212
- name: Set up Go ${{matrix.goversion}} on ${{matrix.os}}
1313
uses: actions/setup-go@v6

dials.go

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io"
77
"reflect"
8+
"slices"
89

910
"github.com/vimeo/dials/ptrify"
1011
)
@@ -70,6 +71,16 @@ type Params[T any] struct {
7071
// - DelayInitialVerification was set to true when Config was called
7172
// - EnableVerification has not been called (without it returning an error)
7273
CallGlobalCallbacksAfterVerificationEnabled bool
74+
75+
// If enabled, Dials will retain the slice of reported source-values so
76+
// it can render a status page using [github.com/vimeo/go-status-page]
77+
//
78+
// This feature is experimental and go-status-page may generate panics
79+
// while rendering pages.
80+
//
81+
// It is heavily recommended that users of this functionality set
82+
// `statuspage:"-"` tags on any sensitive fields with secrets/credentials, etc.
83+
EnableStatusPage bool
7384
}
7485

7586
// Config populates the passed in config struct by reading the values from the
@@ -138,9 +149,11 @@ func (p Params[T]) Config(ctx context.Context, t *T, sources ...Source) (*Dials[
138149

139150
nv, _ := newValue.(*T)
140151

152+
dumpStackCh := make(chan dumpSourceStack[T])
141153
d := &Dials[T]{
142154
updatesChan: make(chan *T, 1),
143155
params: p,
156+
dumpStack: dumpStackCh,
144157
}
145158
d.value.Store(&versionedConfig[T]{serial: 0, cfg: nv})
146159

@@ -166,8 +179,15 @@ func (p Params[T]) Config(ctx context.Context, t *T, sources ...Source) (*Dials[
166179

167180
monCtl := make(chan verifyEnable[T], 3)
168181
d.monCtl = monCtl
169-
go d.monitor(ctx, tVal.Interface().(*T), computed, watcherChan, monCtl)
182+
go d.monitor(ctx, tVal.Interface().(*T), computed, watcherChan, monCtl, dumpStackCh)
170183
}
184+
if p.EnableStatusPage {
185+
if !someoneWatching {
186+
d.sourceVals.Store(&computed)
187+
}
188+
d.defVal = tVal.Interface().(*T)
189+
}
190+
171191
return d, nil
172192
}
173193

@@ -211,6 +231,15 @@ type valueUpdate struct {
211231

212232
func (valueUpdate) isStatusReport() {}
213233

234+
type dumpSourceStackResponse[T any] struct {
235+
stack []sourceValue
236+
serial CfgSerial[T]
237+
}
238+
239+
type dumpSourceStack[T any] struct {
240+
resp chan<- dumpSourceStackResponse[T]
241+
}
242+
214243
type watcherDone struct {
215244
source Source
216245
}
@@ -355,13 +384,20 @@ type CfgSerial[T any] struct {
355384

356385
// Events returns a channel that will get a message every time the configuration
357386
// is updated.
387+
//
388+
// NOTE: In general, it is preferable to register a callback with
389+
// [Dials.RegisterCallback], due to a cleaner interface and the ability to
390+
// register multiple callbacks. Additionally, [NewConfigHandler]
391+
// implementations get both the old and new configs, reducing the amount of
392+
// state required to handle new events.
358393
func (d *Dials[T]) Events() <-chan *T {
359394
return d.updatesChan
360395
}
361396

362397
// Fill populates the passed struct with the current value of the configuration.
363398
// It is a thin wrapper around assignment
364-
// deprecated: assign return value from View() instead
399+
//
400+
// Deprecated: assign return value from View() instead. (this is a legacy method that predates generics)
365401
func (d *Dials[T]) Fill(blankConfig *T) {
366402
*blankConfig = *d.View()
367403
}
@@ -634,12 +670,14 @@ func (d *Dials[T]) monitor(
634670
sourceValues []sourceValue,
635671
watcherChan chan watchStatusUpdate,
636672
monCtl <-chan verifyEnable[T],
673+
dumpStack <-chan dumpSourceStack[T],
637674
) {
638675
defer close(d.cbch)
639676
skipVerify := d.params.DelayInitialVerification
640677
for {
641678
select {
642679
case <-ctx.Done():
680+
d.sourceVals.Store(&sourceValues)
643681
return
644682
case v := <-monCtl:
645683
if !skipVerify {
@@ -654,6 +692,19 @@ func (d *Dials[T]) monitor(
654692
continue
655693
}
656694
skipVerify = !d.monitorEnableVerify(v)
695+
case v := <-dumpStack:
696+
// fetch the serial from inside the monitor because it
697+
// guarantees that we render a consistent view.
698+
_, serial := d.ViewVersion()
699+
select {
700+
case v.resp <- dumpSourceStackResponse[T]{
701+
stack: slices.Clone(sourceValues),
702+
serial: serial,
703+
}:
704+
close(v.resp)
705+
default:
706+
// besteffort response
707+
}
657708
case watchTab := <-watcherChan:
658709
switch v := watchTab.(type) {
659710
case *valueUpdate:
@@ -680,6 +731,7 @@ func (d *Dials[T]) monitor(
680731
case *watcherDone:
681732
if !d.markSourceDone(ctx, sourceValues, v) {
682733
// if there are no watching sources, just exit.
734+
d.sourceVals.Store(&sourceValues)
683735
return
684736
}
685737
default:

dials_118.go

Lines changed: 0 additions & 32 deletions
This file was deleted.

dials_119.go

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
package dials
44

55
import (
6+
"fmt"
7+
"net/http"
68
"sync/atomic"
9+
10+
"golang.org/x/net/html"
11+
"golang.org/x/net/html/atom"
12+
13+
statuspage "github.com/vimeo/go-status-page"
714
)
815

916
// Dials is the main access point for your configuration.
@@ -13,6 +20,12 @@ type Dials[T any] struct {
1320
params Params[T]
1421
cbch chan<- userCallbackEvent
1522
monCtl chan<- verifyEnable[T]
23+
dumpStack chan<- dumpSourceStack[T]
24+
25+
// sourceVals and defVal are only present if the status page is enabled
26+
// and there are no watching sources. (or the monitor has exited)
27+
sourceVals atomic.Pointer[[]sourceValue]
28+
defVal *T
1629
}
1730

1831
// View returns the configuration struct populated.
@@ -23,11 +36,145 @@ func (d *Dials[T]) View() *T {
2336
return versioned.cfg
2437
}
2538

26-
// View returns the configuration struct populated, and an opaque token.
39+
// ViewVersion returns the configuration struct populated, and an opaque token.
2740
func (d *Dials[T]) ViewVersion() (*T, CfgSerial[T]) {
2841
versioned := d.value.Load()
2942
// v cannot be nil because we initialize this value immediately after
3043
// creating the the Dials object
3144
return versioned.cfg, CfgSerial[T]{s: versioned.serial, cfg: versioned.cfg}
45+
}
46+
47+
// ServeHTTP is only active if [Params.EnableStatusPage] was set to true when creating the dials instance.
48+
//
49+
// This is experimental and may panic while serving, buyer beware!!!
50+
//
51+
// It is heavily recommended that users of this functionality set
52+
// `statuspage:"-"` tags on any sensitive fields with secrets/credentials, etc.
53+
func (d *Dials[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) {
54+
if !d.params.EnableStatusPage {
55+
http.Error(w, "Dials status page not enabled. EnableStatusPage must be set in dials.Params.", http.StatusNotFound)
56+
return
57+
}
58+
srcs := d.sourceVals.Load()
59+
_, serial := d.ViewVersion()
60+
if srcs == nil {
61+
// Make the response channel size-1 so the monitor doesn't block on the response
62+
respCh := make(chan dumpSourceStackResponse[T], 1)
63+
// ask the monitor for the stack
64+
select {
65+
case d.dumpStack <- dumpSourceStack[T]{resp: respCh}:
66+
case <-r.Context().Done():
67+
// not worth trying to resolve the race around when the monitor shuts down for a status page.
68+
// just return a 500.
69+
http.Error(w, "context expired while attempting to request the current source-stack; please try again.",
70+
http.StatusInternalServerError)
71+
return
72+
}
73+
select {
74+
case v := <-respCh:
75+
srcs = &v.stack
76+
serial = v.serial
77+
case <-r.Context().Done():
78+
// not worth trying to resolve the race around when the monitor shuts down for a status page.
79+
// just return a 500.
80+
http.Error(w, "context expired while attempting to acquire the current stacks; please try again.",
81+
http.StatusInternalServerError)
82+
return
83+
}
84+
}
85+
86+
root := html.Node{Type: html.DocumentNode}
87+
root.AppendChild(&html.Node{
88+
Type: html.DoctypeNode,
89+
DataAtom: atom.Html,
90+
Data: atom.Html.String(),
91+
})
92+
htmlElem := createElemAtom(atom.Html)
93+
root.AppendChild(htmlElem)
94+
95+
head := createElemAtom(atom.Head)
96+
97+
htmlElem.AppendChild(head)
98+
title := createElemAtom(atom.Title)
99+
title.AppendChild(textNode("Dials Status"))
100+
head.AppendChild(title)
101+
header := createElemAtom(atom.H1)
102+
header.AppendChild(textNode("Dials Status"))
103+
head.AppendChild(header)
104+
105+
body := createElemAtom(atom.Body)
106+
htmlElem.AppendChild(body)
107+
108+
curCfg, genCfgErr := statuspage.GenHTMLNodes(serial.cfg)
109+
if genCfgErr != nil {
110+
http.Error(w, fmt.Sprintf("failed to render status page for current config of type %T: %s.", serial.cfg, genCfgErr),
111+
http.StatusInternalServerError)
112+
return
113+
}
114+
curStatusH2 := createElemAtom(atom.H2)
115+
curStatusH2.AppendChild(textNode("current configuration"))
116+
body.AppendChild(curStatusH2)
117+
curStatusVers := createElemAtom(atom.P)
118+
curStatusVers.AppendChild(textNode(fmt.Sprintf("Current Serial: %d", serial.s)))
119+
body.AppendChild(curStatusVers)
120+
121+
for _, cfgNode := range curCfg {
122+
// add a horizontal rule to separate sections
123+
body.AppendChild(createElemAtom(atom.Hr))
124+
body.AppendChild(cfgNode)
125+
}
126+
defCfgH2 := createElemAtom(atom.H2)
127+
defCfgH2.AppendChild(textNode("Default Configuration"))
128+
body.AppendChild(defCfgH2)
129+
defCfgNodes, defCfgErr := statuspage.GenHTMLNodes(d.defVal)
130+
if defCfgErr != nil {
131+
http.Error(w, fmt.Sprintf("failed to render status page for default config of type %T: %s.",
132+
serial.cfg, defCfgErr),
133+
http.StatusInternalServerError)
134+
return
135+
}
136+
137+
for _, cfgNode := range defCfgNodes {
138+
// add a horizontal rule to separate sections
139+
body.AppendChild(createElemAtom(atom.Hr))
140+
body.AppendChild(cfgNode)
141+
}
142+
143+
for srcIdx, srcVal := range *srcs {
144+
body.AppendChild(createElemAtom(atom.Hr))
145+
srcSectionHeader := createElemAtom(atom.H2)
146+
srcSectionHeader.AppendChild(textNode(fmt.Sprintf("Source %d of type %T (watching %t)", srcIdx, srcVal.source, srcVal.watching)))
147+
body.AppendChild(srcSectionHeader)
148+
srcBodyNodes, srcBodyGenErr := statuspage.GenHTMLNodes(srcVal.value.Interface())
149+
if srcBodyGenErr != nil {
150+
http.Error(w, fmt.Sprintf("failed to render status page for config of type %T on source %d: %s.", serial.cfg, srcIdx, srcBodyGenErr),
151+
http.StatusInternalServerError)
152+
return
153+
}
154+
for _, bn := range srcBodyNodes {
155+
// add a horizontal rule to separate sections
156+
body.AppendChild(createElemAtom(atom.Hr))
157+
body.AppendChild(bn)
158+
}
159+
}
160+
161+
if renderErr := html.Render(w, htmlElem); renderErr != nil {
162+
http.Error(w, fmt.Sprintf("failed to render status page into html for config of type %T : %s.", serial.cfg, renderErr),
163+
http.StatusInternalServerError)
164+
}
165+
}
166+
167+
func createElemAtom(d atom.Atom) *html.Node {
168+
n := &html.Node{Type: html.ElementNode, DataAtom: d, Data: d.String()}
169+
if n.DataAtom == atom.Table {
170+
n.Attr = append(n.Attr, html.Attribute{
171+
Key: "style",
172+
Val: "border: 1px solid; min-width: 100px",
173+
})
174+
}
175+
return n
176+
}
32177

178+
func textNode(d string) *html.Node {
179+
return &html.Node{Type: html.TextNode, Data: d}
33180
}

go.mod

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
module github.com/vimeo/dials
22

3-
go 1.23.0
4-
5-
toolchain go1.24.4
3+
go 1.24.0
64

75
require (
86
cuelang.org/go v0.13.0
@@ -11,7 +9,9 @@ require (
119
github.com/pelletier/go-toml v1.9.5
1210
github.com/spf13/pflag v1.0.6
1311
github.com/stretchr/testify v1.9.0
14-
golang.org/x/text v0.24.0
12+
github.com/vimeo/go-status-page v0.0.0-20251112170003-7780d6931432
13+
golang.org/x/net v0.46.0
14+
golang.org/x/text v0.30.0
1515
gopkg.in/yaml.v2 v2.4.0
1616
)
1717

@@ -28,10 +28,9 @@ require (
2828
github.com/pmezard/go-difflib v1.0.0 // indirect
2929
github.com/protocolbuffers/txtpbfmt v0.0.0-20250129171521-feedd8250727 // indirect
3030
github.com/rogpeppe/go-internal v1.14.1 // indirect
31-
golang.org/x/mod v0.24.0 // indirect
32-
golang.org/x/net v0.39.0 // indirect
31+
golang.org/x/mod v0.28.0 // indirect
3332
golang.org/x/oauth2 v0.29.0 // indirect
34-
golang.org/x/sync v0.13.0 // indirect
35-
golang.org/x/sys v0.32.0 // indirect
33+
golang.org/x/sync v0.17.0 // indirect
34+
golang.org/x/sys v0.37.0 // indirect
3635
gopkg.in/yaml.v3 v3.0.1 // indirect
3736
)

0 commit comments

Comments
 (0)