Skip to content

Commit 3154666

Browse files
authored
logging: Introduce types for structured UI messages + UnmarshalLogMessage() (#167)
1 parent 8d19f21 commit 3154666

File tree

4 files changed

+251
-0
lines changed

4 files changed

+251
-0
lines changed

logging.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
package tfjson
4+
5+
import (
6+
"bytes"
7+
"encoding/json"
8+
"time"
9+
)
10+
11+
// LogMessageLevel represents log level
12+
// See https://github.com/hashicorp/go-hclog/blob/v1.6.3/logger.go#L126-L145
13+
type LogMessageLevel string
14+
15+
const (
16+
// Trace is the most verbose level. Intended to be used for the tracing
17+
// of actions in code, such as function enters/exits, etc.
18+
Trace LogMessageLevel = "trace"
19+
20+
// Debug information for programmer low-level analysis.
21+
Debug LogMessageLevel = "debug"
22+
23+
// Info information about steady state operations.
24+
Info LogMessageLevel = "info"
25+
26+
// Warn information about rare but handled events.
27+
Warn LogMessageLevel = "warn"
28+
29+
// Error information about unrecoverable events.
30+
Error LogMessageLevel = "error"
31+
)
32+
33+
// LogMessage represents a log message emitted from commands
34+
// which support structured log output.
35+
//
36+
// This is implemented via hashicorp/go-hclog which
37+
// defines the format.
38+
type LogMsg interface {
39+
Level() LogMessageLevel
40+
Message() string
41+
Timestamp() time.Time
42+
}
43+
44+
type baseLogMessage struct {
45+
Lvl LogMessageLevel `json:"@level"`
46+
Msg string `json:"@message"`
47+
Time time.Time `json:"@timestamp"`
48+
}
49+
50+
type msgType struct {
51+
// Type represents a message type
52+
// which is documented at https://developer.hashicorp.com/terraform/internals/machine-readable-ui#message-types
53+
Type LogMessageType `json:"type"`
54+
}
55+
56+
func (m baseLogMessage) Level() LogMessageLevel {
57+
return m.Lvl
58+
}
59+
60+
func (m baseLogMessage) Message() string {
61+
return m.Msg
62+
}
63+
64+
func (m baseLogMessage) Timestamp() time.Time {
65+
return m.Time
66+
}
67+
68+
// UnknownLogMessage represents a message of unknown type
69+
type UnknownLogMessage struct {
70+
baseLogMessage
71+
}
72+
73+
func UnmarshalLogMessage(b []byte) (LogMsg, error) {
74+
d := json.NewDecoder(bytes.NewReader(b))
75+
76+
mt := msgType{}
77+
err := d.Decode(&mt)
78+
if err != nil {
79+
return nil, err
80+
}
81+
82+
v, err := unmarshalByType(mt.Type, b)
83+
return v, err
84+
}

logging_generic.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
package tfjson
4+
5+
import "github.com/hashicorp/go-version"
6+
7+
// VersionLogMessage represents information about the Terraform version
8+
// and the version of the schema used for the following messages.
9+
// This is a message of type "version".
10+
type VersionLogMessage struct {
11+
baseLogMessage
12+
Terraform *version.Version `json:"terraform"`
13+
UI *version.Version `json:"ui"`
14+
}
15+
16+
// LogMessage represents a generic human-readable log line
17+
// This is a message of type "log"
18+
type LogMessage struct {
19+
baseLogMessage
20+
}
21+
22+
// DiagnosticLogMessage represents diagnostic warning or error message.
23+
// This is a message of type "diagnostic"
24+
type DiagnosticLogMessage struct {
25+
baseLogMessage
26+
Diagnostic `json:"diagnostic"`
27+
}

logging_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
package tfjson
4+
5+
import (
6+
"testing"
7+
"time"
8+
9+
"github.com/google/go-cmp/cmp"
10+
"github.com/hashicorp/go-version"
11+
)
12+
13+
var cmpOpts = cmp.AllowUnexported(allLogMessageTypes...)
14+
15+
func TestLogging_generic(t *testing.T) {
16+
testCases := []struct {
17+
rawMessage string
18+
expectedMessage LogMsg
19+
}{
20+
{
21+
`{"@level":"info","@message":"Installing provider version: hashicorp/aws v6.8.0...","@module":"terraform.ui","@timestamp":"2025-08-11T15:09:18.827459+00:00","type":"log"}`,
22+
LogMessage{
23+
baseLogMessage: baseLogMessage{
24+
Lvl: Info,
25+
Msg: "Installing provider version: hashicorp/aws v6.8.0...",
26+
Time: time.Date(2025, 8, 11, 15, 9, 18, 827459000, time.UTC),
27+
},
28+
},
29+
},
30+
{
31+
`{"@level":"info","@message":"Terraform 1.9.0","@module":"terraform.ui","@timestamp":"2025-08-11T15:09:15.919212+00:00","terraform":"1.9.0","type":"version","ui":"1.2"}`,
32+
VersionLogMessage{
33+
baseLogMessage: baseLogMessage{
34+
Lvl: Info,
35+
Msg: "Terraform 1.9.0",
36+
Time: time.Date(2025, 8, 11, 15, 9, 15, 919212000, time.UTC),
37+
},
38+
Terraform: version.Must(version.NewVersion("1.9.0")),
39+
UI: version.Must(version.NewVersion("1.2")),
40+
},
41+
},
42+
{
43+
`{"@level":"error","@message":"Error: Unclosed configuration block","@module":"terraform.ui","@timestamp":"2025-08-13T10:40:46.749685+00:00","diagnostic":{"severity":"error","summary":"Unclosed configuration block","detail":"There is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.","range":{"filename":"main.tf","start":{"line":11,"column":30,"byte":153},"end":{"line":11,"column":31,"byte":154}},"snippet":{"context":"resource \"random_pet\" \"name\"","code":"resource \"random_pet\" \"name\" {","start_line":11,"highlight_start_offset":29,"highlight_end_offset":30,"values":[]}},"type":"diagnostic"}`,
44+
DiagnosticLogMessage{
45+
baseLogMessage: baseLogMessage{
46+
Lvl: Error,
47+
Msg: "Error: Unclosed configuration block",
48+
Time: time.Date(2025, 8, 13, 10, 40, 46, 749685000, time.UTC),
49+
},
50+
Diagnostic: Diagnostic{
51+
Severity: DiagnosticSeverityError,
52+
Summary: "Unclosed configuration block",
53+
Detail: "There is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.",
54+
Range: &Range{
55+
Filename: "main.tf",
56+
Start: Pos{
57+
Line: 11,
58+
Column: 30,
59+
Byte: 153,
60+
},
61+
End: Pos{
62+
Line: 11,
63+
Column: 31,
64+
Byte: 154,
65+
},
66+
},
67+
Snippet: &DiagnosticSnippet{
68+
Context: ptrToString(`resource "random_pet" "name"`),
69+
Code: `resource "random_pet" "name" {`,
70+
StartLine: 11,
71+
HighlightStartOffset: 29,
72+
HighlightEndOffset: 30,
73+
Values: []DiagnosticExpressionValue{},
74+
},
75+
},
76+
},
77+
},
78+
{
79+
`{"@level":"debug","@message":"Foobar","@module":"terraform.ui","@timestamp":"2025-08-11T15:09:18.827459+00:00","type":"FOO"}`,
80+
UnknownLogMessage{
81+
baseLogMessage: baseLogMessage{
82+
Lvl: Debug,
83+
Msg: "Foobar",
84+
Time: time.Date(2025, 8, 11, 15, 9, 18, 827459000, time.UTC),
85+
},
86+
},
87+
},
88+
}
89+
90+
for _, tc := range testCases {
91+
msg, err := UnmarshalLogMessage([]byte(tc.rawMessage))
92+
if err != nil {
93+
t.Fatal(err)
94+
}
95+
if diff := cmp.Diff(tc.expectedMessage, msg, cmpOpts); diff != "" {
96+
t.Fatalf("unexpected message: %s", diff)
97+
}
98+
}
99+
}

logging_types.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
package tfjson
4+
5+
import (
6+
"encoding/json"
7+
)
8+
9+
type LogMessageType string
10+
11+
const (
12+
MessageTypeVersion LogMessageType = "version"
13+
MessageTypeLog LogMessageType = "log"
14+
MessageTypeDiagnostic LogMessageType = "diagnostic"
15+
)
16+
17+
// allLogMessageTypes is a slice containing all recognised message types
18+
// to be passed into cmp.AllowUnexported
19+
var allLogMessageTypes = []any{
20+
VersionLogMessage{},
21+
LogMessage{},
22+
DiagnosticLogMessage{},
23+
UnknownLogMessage{},
24+
}
25+
26+
func unmarshalByType(t LogMessageType, b []byte) (LogMsg, error) {
27+
switch t {
28+
case MessageTypeVersion:
29+
v := VersionLogMessage{}
30+
return v, json.Unmarshal(b, &v)
31+
case MessageTypeLog:
32+
v := LogMessage{}
33+
return v, json.Unmarshal(b, &v)
34+
case MessageTypeDiagnostic:
35+
v := DiagnosticLogMessage{}
36+
return v, json.Unmarshal(b, &v)
37+
}
38+
39+
v := UnknownLogMessage{}
40+
return v, json.Unmarshal(b, &v)
41+
}

0 commit comments

Comments
 (0)