Improved trace functionality & added filters support (#817)

Co-authored-by: Benjamin Perez <benjamin@bexsoft.net>
This commit is contained in:
Alex
2021-06-17 14:21:25 -05:00
committed by GitHub
parent 13f9f6c848
commit 11eb587610
11 changed files with 880 additions and 53 deletions

2
go.mod
View File

@@ -18,7 +18,7 @@ require (
github.com/minio/cli v1.22.0
github.com/minio/direct-csi v1.3.5-0.20210601185811-f7776f7961bf
github.com/minio/kes v0.11.0
github.com/minio/madmin-go v1.0.8
github.com/minio/madmin-go v1.0.12
github.com/minio/mc v0.0.0-20210531030240-fbbae711bdb4
github.com/minio/minio-go/v7 v7.0.11-0.20210517200026-f0518ca447d6
github.com/minio/operator v0.0.0-20210604224119-7e256f98cf90

451
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -174,6 +174,11 @@ export const searchField = {
display: "flex",
justifyContent: "center",
padding: "0 16px",
"& label, & label.MuiInputLabel-shrink": {
fontSize: 10,
transform: "translate(5px, 2px)",
transformOrigin: "top left",
},
"& input": {
fontSize: 12,
fontWeight: 700,
@@ -700,3 +705,10 @@ export const inputFieldStyles = {
color: "#b53b4b",
},
};
export const inlineCheckboxes = {
inlineCheckboxes: {
display: "flex",
justifyContent: "flex-start",
},
}

View File

@@ -29,6 +29,7 @@ import {
actionsTray,
containerForHeader,
searchField,
inlineCheckboxes
} from "../Common/FormComponents/common/styleLibrary";
import CheckboxWrapper from "../Common/FormComponents/CheckboxWrapper/CheckboxWrapper";
import PageHeader from "../Common/PageHeader/PageHeader";
@@ -65,10 +66,7 @@ const styles = (theme: Theme) =>
scanData: {
fontSize: 13,
},
inlineCheckboxes: {
display: "flex",
justifyContent: "flex-start",
},
...inlineCheckboxes,
...actionsTray,
...searchField,
...containerForHeader(theme.spacing(4)),

View File

@@ -14,23 +14,35 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React, { useEffect } from "react";
import React, { useState, Fragment } from "react";
import { Grid, Button, TextField } from "@material-ui/core";
import { IMessageEvent, w3cwebsocket as W3CWebSocket } from "websocket";
import { AppState } from "../../../store";
import { connect } from "react-redux";
import { traceMessageReceived, traceResetMessages } from "./actions";
import { traceMessageReceived, traceResetMessages, setTraceStarted } from "./actions";
import { TraceMessage } from "./types";
import { createStyles, Theme, withStyles } from "@material-ui/core/styles";
import { niceBytes, timeFromDate } from "../../../common/utils";
import { wsProtocol } from "../../../utils/wsUtils";
import { containerForHeader } from "../Common/FormComponents/common/styleLibrary";
import { Grid } from "@material-ui/core";
import {
containerForHeader,
searchField,
actionsTray,
hrClass,
inlineCheckboxes,
} from "../Common/FormComponents/common/styleLibrary";
import TableWrapper from "../Common/TableWrapper/TableWrapper";
import PageHeader from "../Common/PageHeader/PageHeader";
import CheckboxWrapper from "../Common/FormComponents/CheckboxWrapper/CheckboxWrapper";
import moment from "moment/moment";
const styles = (theme: Theme) =>
createStyles({
paperContainer: {
padding: 15,
paddingLeft: 50,
display: "flex",
},
logList: {
background: "white",
height: "400px",
@@ -52,6 +64,31 @@ const styles = (theme: Theme) =>
timeItem: {
width: 100,
},
labelCheckboxes: {
fontSize: 16,
fontWeight: 700,
paddingTop: 19,
},
startButton: {
textAlign: "right",
},
...actionsTray,
...searchField,
...hrClass,
...inlineCheckboxes,
searchField: {
...searchField.searchField,
margin: "0 5px",
"&:first-of-type": {
marginLeft: 0,
},
"&:last-of-type": {
marginRight: 0,
},
},
tableWrapper: {
height: "calc(100vh - 292px)",
},
...containerForHeader(theme.spacing(4)),
});
@@ -59,30 +96,63 @@ interface ITrace {
classes: any;
traceMessageReceived: typeof traceMessageReceived;
traceResetMessages: typeof traceResetMessages;
setTraceStarted: typeof setTraceStarted;
messages: TraceMessage[];
namespace: string;
tenant: string;
traceStarted: boolean;
}
var c: any = null;
const Trace = ({
classes,
traceMessageReceived,
traceResetMessages,
setTraceStarted,
traceStarted,
messages,
}: ITrace) => {
useEffect(() => {
const [statusCode, setStatusCode] = useState<string>("");
const [method, setMethod] = useState<string>("");
const [func, setFunc] = useState<string>("");
const [path, setPath] = useState<string>("");
const [threshold, setThreshold] = useState<number>(0);
const [all, setAll] = useState<boolean>(false);
const [s3, setS3] = useState<boolean>(true);
const [internal, setInternal] = useState<boolean>(false);
const [storage, setStorage] = useState<boolean>(false);
const [os, setOS] = useState<boolean>(false);
const [errors, setErrors] = useState<boolean>(false);
const startTrace = () => {
traceResetMessages();
const url = new URL(window.location.toString());
const isDev = process.env.NODE_ENV === "development";
const port = isDev ? "9090" : url.port;
let calls = `${s3 ? "s3," : ""}${internal ? "internal," : ""}${
storage ? "storage," : ""
}${os ? "os," : ""}`;
if (all) {
calls = "all";
}
const wsProt = wsProtocol(url.protocol);
const c = new W3CWebSocket(`${wsProt}://${url.hostname}:${port}/ws/trace`);
c = new W3CWebSocket(
`${wsProt}://${
url.hostname
}:${port}/ws/trace?calls=${calls}&threshold=${threshold}&onlyErrors=${
errors ? "yes" : "no"
}&statusCode=${statusCode}&method=${method}&funcname=${func}&path=${path}`
);
let interval: any | null = null;
if (c !== null) {
c.onopen = () => {
console.log("WebSocket Client Connected");
setTraceStarted(true);
c.send("ok");
interval = setInterval(() => {
c.send("ok");
@@ -97,20 +167,202 @@ const Trace = ({
c.onclose = () => {
clearInterval(interval);
console.log("connection closed by server");
setTraceStarted(false);
};
return () => {
c.close(1000);
clearInterval(interval);
console.log("closing websockets");
setTraceStarted(false);
};
}
}, [traceMessageReceived, traceResetMessages]);
};
const stopTrace = () => {
c.close(1000);
setTraceStarted(false);
};
return (
<React.Fragment>
<Fragment>
<PageHeader label={"Trace"} />
<Grid container>
<Grid item xs={12} className={classes.container}>
<Grid item xs={12} className={classes.actionsTray}>
<TextField
placeholder="Status Code"
className={classes.searchField}
id="status-code"
label=""
InputProps={{
disableUnderline: true,
}}
value={statusCode}
onChange={(e) => {
setStatusCode(e.target.value);
}}
disabled={traceStarted}
/>
<TextField
placeholder="Method"
className={classes.searchField}
id="method"
label=""
InputProps={{
disableUnderline: true,
}}
value={method}
onChange={(e) => {
setMethod(e.target.value);
}}
disabled={traceStarted}
/>
<TextField
placeholder="Function Name"
className={classes.searchField}
id="func-name"
label=""
disabled={traceStarted}
InputProps={{
disableUnderline: true,
}}
value={func}
onChange={(e) => {
setFunc(e.target.value);
}}
/>
<TextField
placeholder="Path"
className={classes.searchField}
id="path"
label=""
disabled={traceStarted}
InputProps={{
disableUnderline: true,
}}
value={path}
onChange={(e) => {
setPath(e.target.value);
}}
/>
<TextField
type="number"
className={classes.searchField}
id="fthreshold"
label="Response Threshold"
disabled={traceStarted}
InputProps={{
disableUnderline: true,
}}
inputProps={{
min: 0,
}}
value={threshold}
onChange={(e) => {
setThreshold(parseInt(e.target.value));
}}
/>
</Grid>
<Grid item xs={12} className={classes.inlineCheckboxes}>
<span className={classes.labelCheckboxes}>Calls to trace:</span>
<CheckboxWrapper
checked={all}
id={"all_calls"}
name={"all_calls"}
label={"All"}
onChange={(item) => {
setAll(item.target.checked);
}}
value={"all"}
disabled={traceStarted}
/>
<CheckboxWrapper
checked={s3 || all}
id={"s3_calls"}
name={"s3_calls"}
label={"S3"}
onChange={(item) => {
setS3(item.target.checked);
}}
value={"s3"}
disabled={all || traceStarted}
/>
<CheckboxWrapper
checked={internal || all}
id={"internal_calls"}
name={"internal_calls"}
label={"Internal"}
onChange={(item) => {
setInternal(item.target.checked);
}}
value={"internal"}
disabled={all || traceStarted}
/>
<CheckboxWrapper
checked={storage || all}
id={"storage_calls"}
name={"storage_calls"}
label={"Storage"}
onChange={(item) => {
setStorage(item.target.checked);
}}
value={"storage"}
disabled={all || traceStarted}
/>
<CheckboxWrapper
checked={os || all}
id={"os_calls"}
name={"os_calls"}
label={"OS"}
onChange={(item) => {
setOS(item.target.checked);
}}
value={"os"}
disabled={all || traceStarted}
/>
<span className={classes.labelCheckboxes}>
&nbsp; &nbsp; &nbsp; | &nbsp; &nbsp; &nbsp;
</span>
<CheckboxWrapper
checked={errors}
id={"only_errors"}
name={"only_errors"}
label={"Display only Errors"}
onChange={(item) => {
setErrors(item.target.checked);
}}
value={"only_errors"}
disabled={traceStarted}
/>
</Grid>
<Grid item xs={12} className={classes.startButton}>
{!traceStarted && (
<Button
type="submit"
variant="contained"
color="primary"
disabled={traceStarted}
onClick={startTrace}
>
Start
</Button>
)}
{traceStarted && (
<Button
type="button"
variant="contained"
color="primary"
onClick={stopTrace}
>
Stop
</Button>
)}
</Grid>
<Grid item xs={12}>
<br />
</Grid>
<TableWrapper
itemActions={[]}
columns={[
@@ -160,22 +412,29 @@ const Trace = ({
records={messages}
entityName="Traces"
idField="api"
customEmptyMessage="There are no traced Elements yet"
customEmptyMessage={
traceStarted
? "No Traced elements received yet"
: "Trace is not started yet"
}
customPaperHeight={classes.tableWrapper}
autoScrollToBottom
/>
</Grid>
</Grid>
</React.Fragment>
</Fragment>
);
};
const mapState = (state: AppState) => ({
messages: state.trace.messages,
traceStarted: state.trace.traceStarted,
});
const connector = connect(mapState, {
traceMessageReceived: traceMessageReceived,
traceResetMessages: traceResetMessages,
setTraceStarted,
});
export default connector(withStyles(styles)(Trace));

View File

@@ -16,8 +16,9 @@
import { TraceMessage } from "./types";
export const TRACE_MESSAGE_RECEIVED = "TRACE_MESSAGE_RECEIVED";
export const TRACE_RESET_MESSAGES = "TRACE_RESET_MESSAGES";
export const TRACE_MESSAGE_RECEIVED = "TRACE/MESSAGE_RECEIVED";
export const TRACE_RESET_MESSAGES = "TRACE/RESET_MESSAGES";
export const TRACE_SET_STARTED = "TRACE/SET_STARTED";
interface TraceMessageReceivedAction {
type: typeof TRACE_MESSAGE_RECEIVED;
@@ -28,9 +29,15 @@ interface TraceResetMessagesAction {
type: typeof TRACE_RESET_MESSAGES;
}
interface TraceSetStarted {
type: typeof TRACE_SET_STARTED;
status: boolean;
}
export type TraceActionTypes =
| TraceMessageReceivedAction
| TraceResetMessagesAction;
| TraceResetMessagesAction
| TraceSetStarted;
export function traceMessageReceived(message: TraceMessage) {
return {
@@ -44,3 +51,10 @@ export function traceResetMessages() {
type: TRACE_RESET_MESSAGES,
};
}
export function setTraceStarted(status: boolean) {
return {
type: TRACE_SET_STARTED,
status
};
}

View File

@@ -17,16 +17,19 @@
import {
TRACE_MESSAGE_RECEIVED,
TRACE_RESET_MESSAGES,
TRACE_SET_STARTED,
TraceActionTypes,
} from "./actions";
import { TraceMessage } from "./types";
export interface TraceState {
messages: TraceMessage[];
traceStarted: boolean;
}
const initialState: TraceState = {
messages: [],
traceStarted: false,
};
export function traceReducer(
@@ -44,6 +47,11 @@ export function traceReducer(
...state,
messages: [],
};
case TRACE_SET_STARTED:
return {
...state,
traceStarted: action.status,
}
default:
return state;
}

View File

@@ -47,15 +47,54 @@ type callStats struct {
Ttfb string `json:"timeToFirstByte"`
}
type serviceTraceOpts struct {
AllTraffic bool
ErrOnly bool
// trace filters
func matchTrace(opts TraceRequest, traceInfo madmin.ServiceTraceInfo) bool {
statusCode := int(opts.statusCode)
method := opts.method
funcName := opts.funcName
apiPath := opts.path
if statusCode == 0 && method == "" && funcName == "" && apiPath == "" {
// no specific filtering found trace all the requests
return true
}
// Filter request path if passed by the user
if apiPath != "" {
pathToLookup := strings.ToLower(apiPath)
pathFromTrace := strings.ToLower(traceInfo.Trace.ReqInfo.Path)
return strings.Contains(pathFromTrace, pathToLookup)
}
// Filter response status codes if passed by the user
if statusCode > 0 {
statusCodeFromTrace := traceInfo.Trace.RespInfo.StatusCode
return statusCodeFromTrace == statusCode
}
// Filter request method if passed by the user
if method != "" {
methodFromTrace := traceInfo.Trace.ReqInfo.Method
return methodFromTrace == method
}
if funcName != "" {
funcToLookup := strings.ToLower(funcName)
funcFromTrace := strings.ToLower(traceInfo.Trace.FuncName)
return strings.Contains(funcFromTrace, funcToLookup)
}
return true
}
// startTraceInfo starts trace of the servers
func startTraceInfo(ctx context.Context, conn WSConn, client MinioAdmin, opts serviceTraceOpts) error {
func startTraceInfo(ctx context.Context, conn WSConn, client MinioAdmin, opts TraceRequest) error {
// Start listening on all trace activity.
traceCh := client.serviceTrace(ctx, opts.AllTraffic, opts.ErrOnly)
traceCh := client.serviceTrace(ctx, opts.threshold, opts.s3, opts.internal, opts.storage, opts.os, opts.onlyErrors)
for {
select {
case <-ctx.Done():
@@ -69,17 +108,19 @@ func startTraceInfo(ctx context.Context, conn WSConn, client MinioAdmin, opts se
LogError("error on serviceTrace: %v", traceInfo.Err)
return traceInfo.Err
}
// Serialize message to be sent
traceInfoBytes, err := json.Marshal(shortTrace(&traceInfo))
if err != nil {
LogError("error on json.Marshal: %v", err)
return err
}
// Send Message through websocket connection
err = conn.writeMessage(websocket.TextMessage, traceInfoBytes)
if err != nil {
LogError("error writeMessage: %v", err)
return err
if matchTrace(opts, traceInfo) {
// Serialize message to be sent
traceInfoBytes, err := json.Marshal(shortTrace(&traceInfo))
if err != nil {
LogError("error on json.Marshal: %v", err)
return err
}
// Send Message through websocket connection
err = conn.writeMessage(websocket.TextMessage, traceInfoBytes)
if err != nil {
LogError("error writeMessage: %v", err)
return err
}
}
}
}
@@ -100,6 +141,7 @@ func shortTrace(info *madmin.ServiceTraceInfo) shortTraceMsg {
s.CallStats.Duration = t.CallStats.Latency.String()
s.CallStats.Rx = t.CallStats.InputBytes
s.CallStats.Tx = t.CallStats.OutputBytes
s.CallStats.Ttfb = t.CallStats.TimeToFirstByte.String()
if host, ok := t.ReqInfo.Headers["Host"]; ok {
s.Host = strings.Join(host, "")

View File

@@ -27,11 +27,11 @@ import (
)
// assigning mock at runtime instead of compile time
var minioServiceTraceMock func(ctx context.Context, allTrace, errTrace bool) <-chan madmin.ServiceTraceInfo
var minioServiceTraceMock func(ctx context.Context, threshold int64, s3, internal, storage, os, errTrace bool) <-chan madmin.ServiceTraceInfo
// mock function of listPolicies()
func (ac adminClientMock) serviceTrace(ctx context.Context, allTrace, errTrace bool) <-chan madmin.ServiceTraceInfo {
return minioServiceTraceMock(ctx, allTrace, errTrace)
func (ac adminClientMock) serviceTrace(ctx context.Context, threshold int64, s3, internal, storage, os, errTrace bool) <-chan madmin.ServiceTraceInfo {
return minioServiceTraceMock(ctx, threshold, s3, internal, storage, os, errTrace)
}
func TestAdminTrace(t *testing.T) {
@@ -48,7 +48,7 @@ func TestAdminTrace(t *testing.T) {
// Test-1: Serve Trace with no errors until trace finishes sending
// define mock function behavior for minio server Trace
minioServiceTraceMock = func(ctx context.Context, allTrace, errTrace bool) <-chan madmin.ServiceTraceInfo {
minioServiceTraceMock = func(ctx context.Context, threshold int64, s3, internal, storage, os, errTrace bool) <-chan madmin.ServiceTraceInfo {
ch := make(chan madmin.ServiceTraceInfo)
// Only success, start a routine to start reading line by line.
go func(ch chan<- madmin.ServiceTraceInfo) {
@@ -82,7 +82,7 @@ func TestAdminTrace(t *testing.T) {
writesCount++
return nil
}
if err := startTraceInfo(ctx, mockWSConn, adminClient, serviceTraceOpts{AllTraffic: true, ErrOnly: false}); err != nil {
if err := startTraceInfo(ctx, mockWSConn, adminClient, TraceRequest{s3: true, internal: true, storage: true, os: true, onlyErrors: false}); err != nil {
t.Errorf("Failed on %s:, error occurred: %s", function, err.Error())
}
// check that the TestReceiver got the same number of data from trace.
@@ -94,13 +94,13 @@ func TestAdminTrace(t *testing.T) {
connWriteMessageMock = func(messageType int, data []byte) error {
return fmt.Errorf("error on write")
}
if err := startTraceInfo(ctx, mockWSConn, adminClient, serviceTraceOpts{}); assert.Error(err) {
if err := startTraceInfo(ctx, mockWSConn, adminClient, TraceRequest{}); assert.Error(err) {
assert.Equal("error on write", err.Error())
}
// Test-3: error happens on serviceTrace Minio, trace should stop
// and error shall be returned.
minioServiceTraceMock = func(ctx context.Context, allTrace, errTrace bool) <-chan madmin.ServiceTraceInfo {
minioServiceTraceMock = func(ctx context.Context, threshold int64, s3, internal, storage, os, errTrace bool) <-chan madmin.ServiceTraceInfo {
ch := make(chan madmin.ServiceTraceInfo)
// Only success, start a routine to start reading line by line.
go func(ch chan<- madmin.ServiceTraceInfo) {
@@ -120,7 +120,7 @@ func TestAdminTrace(t *testing.T) {
connWriteMessageMock = func(messageType int, data []byte) error {
return nil
}
if err := startTraceInfo(ctx, mockWSConn, adminClient, serviceTraceOpts{}); assert.Error(err) {
if err := startTraceInfo(ctx, mockWSConn, adminClient, TraceRequest{}); assert.Error(err) {
assert.Equal("error on trace", err.Error())
}
}

View File

@@ -89,7 +89,7 @@ type MinioAdmin interface {
serverInfo(ctx context.Context) (madmin.InfoMessage, error)
startProfiling(ctx context.Context, profiler madmin.ProfilerType) ([]madmin.StartProfilingResult, error)
stopProfiling(ctx context.Context) (io.ReadCloser, error)
serviceTrace(ctx context.Context, allTrace, errTrace bool) <-chan madmin.ServiceTraceInfo
serviceTrace(ctx context.Context, threshold int64, s3, internal, storage, os, errTrace bool) <-chan madmin.ServiceTraceInfo
getLogs(ctx context.Context, node string, lineCnt int, logKind string) <-chan madmin.LogInfo
accountInfo(ctx context.Context) (madmin.AccountInfo, error)
heal(ctx context.Context, bucket, prefix string, healOpts madmin.HealOpts, clientToken string,
@@ -253,13 +253,16 @@ func (ac adminClient) stopProfiling(ctx context.Context) (io.ReadCloser, error)
}
// implements madmin.ServiceTrace()
func (ac adminClient) serviceTrace(ctx context.Context, allTrace, errTrace bool) <-chan madmin.ServiceTraceInfo {
func (ac adminClient) serviceTrace(ctx context.Context, threshold int64, s3, internal, storage, os, errTrace bool) <-chan madmin.ServiceTraceInfo {
thresholdT := time.Duration(threshold)
tracingOptions := madmin.ServiceTraceOpts{
S3: true,
OnlyErrors: errTrace,
Internal: allTrace,
Storage: allTrace,
OS: allTrace,
Internal: internal,
Storage: storage,
OS: os,
Threshold: thresholdT,
}
return ac.client.ServiceTrace(ctx, tracingOptions)

View File

@@ -20,6 +20,7 @@ import (
"context"
"net"
"net/http"
"strconv"
"strings"
"time"
@@ -81,6 +82,20 @@ type wsConn struct {
conn *websocket.Conn
}
// Types for trace request. this adds support for calls, threshold, status and extra filters
type TraceRequest struct {
s3 bool
internal bool
storage bool
os bool
threshold int64
onlyErrors bool
statusCode int64
method string
funcName string
path string
}
func (c wsConn) writeMessage(messageType int, data []byte) error {
return c.conn.WriteMessage(messageType, data)
}
@@ -122,7 +137,35 @@ func serveWS(w http.ResponseWriter, req *http.Request) {
closeWsConn(conn)
return
}
go wsAdminClient.trace()
calls := req.URL.Query().Get("calls")
threshold, _ := strconv.ParseInt(req.URL.Query().Get("threshold"), 10, 64)
onlyErrors := req.URL.Query().Get("onlyErrors")
stCode, errorStCode := strconv.ParseInt(req.URL.Query().Get("statusCode"), 10, 64)
method := req.URL.Query().Get("method")
funcName := req.URL.Query().Get("funcname")
path := req.URL.Query().Get("path")
statusCode := int64(0)
if errorStCode == nil {
statusCode = stCode
}
traceRequestItem := TraceRequest{
s3: strings.Contains(calls, "s3") || strings.Contains(calls, "all"),
internal: strings.Contains(calls, "internal") || strings.Contains(calls, "all"),
storage: strings.Contains(calls, "storage") || strings.Contains(calls, "all"),
os: strings.Contains(calls, "os") || strings.Contains(calls, "all"),
onlyErrors: onlyErrors == "yes",
threshold: threshold,
statusCode: statusCode,
method: method,
funcName: funcName,
path: path,
}
go wsAdminClient.trace(traceRequestItem)
case strings.HasPrefix(wsPath, `/console`):
wsAdminClient, err := newWebSocketAdminClient(conn, session)
if err != nil {
@@ -254,7 +297,7 @@ func closeWsConn(conn *websocket.Conn) {
// trace serves madmin.ServiceTraceInfo
// on a Websocket connection.
func (wsc *wsAdminClient) trace() {
func (wsc *wsAdminClient) trace(traceRequestItem TraceRequest) {
defer func() {
LogInfo("trace stopped")
// close connection after return
@@ -264,10 +307,7 @@ func (wsc *wsAdminClient) trace() {
ctx := wsReadClientCtx(wsc.conn)
err := startTraceInfo(ctx, wsc.conn, wsc.client, serviceTraceOpts{
AllTraffic: false,
ErrOnly: false,
})
err := startTraceInfo(ctx, wsc.conn, wsc.client, traceRequestItem)
sendWsCloseMessage(wsc.conn, err)
}