Jelajahi Sumber

Datasource for Grafana logging platform

- new builtin datasource plugin "Logging" (likely going to be renamed)
- plugin implements no panel ctrls yet, only ships datasource
- new models for logging data as first class citizen (aside from table
  and time_series model)
- Logs as new view for Explore
- JSON view for development

Testable only against existing logish deployment.
Then test with queries like `{job="..."} regexp`.
David Kaltschmidt 7 tahun lalu
induk
melakukan
3297ae462d

+ 2 - 0
pkg/plugins/datasource_plugin.go

@@ -17,12 +17,14 @@ import (
 	plugin "github.com/hashicorp/go-plugin"
 )
 
+// DataSourcePlugin contains all metadata about a datasource plugin
 type DataSourcePlugin struct {
 	FrontendPluginBase
 	Annotations  bool              `json:"annotations"`
 	Metrics      bool              `json:"metrics"`
 	Alerting     bool              `json:"alerting"`
 	Explore      bool              `json:"explore"`
+	Logs         bool              `json:"logs"`
 	QueryOptions map[string]bool   `json:"queryOptions,omitempty"`
 	BuiltIn      bool              `json:"builtIn,omitempty"`
 	Mixed        bool              `json:"mixed,omitempty"`

+ 89 - 19
public/app/containers/Explore/Explore.tsx

@@ -11,6 +11,7 @@ import { parse as parseDate } from 'app/core/utils/datemath';
 import ElapsedTime from './ElapsedTime';
 import QueryRows from './QueryRows';
 import Graph from './Graph';
+import Logs from './Logs';
 import Table from './Table';
 import TimePicker, { DEFAULT_RANGE } from './TimePicker';
 import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
@@ -58,12 +59,17 @@ interface IExploreState {
   initialDatasource?: string;
   latency: number;
   loading: any;
+  logsResult: any;
   queries: any;
   queryError: any;
   range: any;
   requestOptions: any;
   showingGraph: boolean;
+  showingLogs: boolean;
   showingTable: boolean;
+  supportsGraph: boolean | null;
+  supportsLogs: boolean | null;
+  supportsTable: boolean | null;
   tableResult: any;
 }
 
@@ -82,12 +88,17 @@ export class Explore extends React.Component<any, IExploreState> {
       initialDatasource: datasource,
       latency: 0,
       loading: false,
+      logsResult: null,
       queries: ensureQueries(queries),
       queryError: null,
       range: range || { ...DEFAULT_RANGE },
       requestOptions: null,
       showingGraph: true,
+      showingLogs: true,
       showingTable: true,
+      supportsGraph: null,
+      supportsLogs: null,
+      supportsTable: null,
       tableResult: null,
       ...props.initialState,
     };
@@ -124,17 +135,29 @@ export class Explore extends React.Component<any, IExploreState> {
   }
 
   async setDatasource(datasource) {
+    const supportsGraph = datasource.meta.metrics;
+    const supportsLogs = datasource.meta.logs;
+    const supportsTable = datasource.meta.metrics;
+    let datasourceError = null;
+
     try {
       const testResult = await datasource.testDatasource();
-      if (testResult.status === 'success') {
-        this.setState({ datasource, datasourceError: null, datasourceLoading: false }, () => this.handleSubmit());
-      } else {
-        this.setState({ datasource: datasource, datasourceError: testResult.message, datasourceLoading: false });
-      }
+      datasourceError = testResult.status === 'success' ? null : testResult.message;
     } catch (error) {
-      const message = (error && error.statusText) || error;
-      this.setState({ datasource: datasource, datasourceError: message, datasourceLoading: false });
+      datasourceError = (error && error.statusText) || error;
     }
+
+    this.setState(
+      {
+        datasource,
+        datasourceError,
+        supportsGraph,
+        supportsLogs,
+        supportsTable,
+        datasourceLoading: false,
+      },
+      () => datasourceError === null && this.handleSubmit()
+    );
   }
 
   getRef = el => {
@@ -157,6 +180,7 @@ export class Explore extends React.Component<any, IExploreState> {
       datasourceError: null,
       datasourceLoading: true,
       graphResult: null,
+      logsResult: null,
       tableResult: null,
     });
     const datasource = await this.props.datasourceSrv.get(option.value);
@@ -193,6 +217,10 @@ export class Explore extends React.Component<any, IExploreState> {
     this.setState(state => ({ showingGraph: !state.showingGraph }));
   };
 
+  handleClickLogsButton = () => {
+    this.setState(state => ({ showingLogs: !state.showingLogs }));
+  };
+
   handleClickSplit = () => {
     const { onChangeSplit } = this.props;
     if (onChangeSplit) {
@@ -214,16 +242,19 @@ export class Explore extends React.Component<any, IExploreState> {
   };
 
   handleSubmit = () => {
-    const { showingGraph, showingTable } = this.state;
-    if (showingTable) {
+    const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
+    if (showingTable && supportsTable) {
       this.runTableQuery();
     }
-    if (showingGraph) {
+    if (showingGraph && supportsGraph) {
       this.runGraphQuery();
     }
+    if (showingLogs && supportsLogs) {
+      this.runLogsQuery();
+    }
   };
 
-  buildQueryOptions(targetOptions: { format: string; instant: boolean }) {
+  buildQueryOptions(targetOptions: { format: string; instant?: boolean }) {
     const { datasource, queries, range } = this.state;
     const resolution = this.el.offsetWidth;
     const absoluteRange = {
@@ -285,6 +316,29 @@ export class Explore extends React.Component<any, IExploreState> {
     }
   }
 
+  async runLogsQuery() {
+    const { datasource, queries } = this.state;
+    if (!hasQuery(queries)) {
+      return;
+    }
+    this.setState({ latency: 0, loading: true, queryError: null, logsResult: null });
+    const now = Date.now();
+    const options = this.buildQueryOptions({
+      format: 'logs',
+    });
+
+    try {
+      const res = await datasource.query(options);
+      const logsData = res.data;
+      const latency = Date.now() - now;
+      this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options });
+    } catch (response) {
+      console.error(response);
+      const queryError = response.data ? response.data.error : response;
+      this.setState({ loading: false, queryError });
+    }
+  }
+
   request = url => {
     const { datasource } = this.state;
     return datasource.metadataRequest(url);
@@ -300,17 +354,23 @@ export class Explore extends React.Component<any, IExploreState> {
       graphResult,
       latency,
       loading,
+      logsResult,
       queries,
       queryError,
       range,
       requestOptions,
       showingGraph,
+      showingLogs,
       showingTable,
+      supportsGraph,
+      supportsLogs,
+      supportsTable,
       tableResult,
     } = this.state;
     const showingBoth = showingGraph && showingTable;
     const graphHeight = showingBoth ? '200px' : '400px';
     const graphButtonActive = showingBoth || showingGraph ? 'active' : '';
+    const logsButtonActive = showingLogs ? 'active' : '';
     const tableButtonActive = showingBoth || showingTable ? 'active' : '';
     const exploreClass = split ? 'explore explore-split' : 'explore';
     const datasources = datasourceSrv.getExploreSources().map(ds => ({
@@ -357,12 +417,21 @@ export class Explore extends React.Component<any, IExploreState> {
             </div>
           ) : null}
           <div className="navbar-buttons">
-            <button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
-              Graph
-            </button>
-            <button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}>
-              Table
-            </button>
+            {supportsGraph ? (
+              <button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
+                Graph
+              </button>
+            ) : null}
+            {supportsTable ? (
+              <button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}>
+                Table
+              </button>
+            ) : null}
+            {supportsLogs ? (
+              <button className={`btn navbar-button ${logsButtonActive}`} onClick={this.handleClickLogsButton}>
+                Logs
+              </button>
+            ) : null}
           </div>
           <TimePicker range={range} onChangeTime={this.handleChangeTime} />
           <div className="navbar-buttons relative">
@@ -395,7 +464,7 @@ export class Explore extends React.Component<any, IExploreState> {
             />
             {queryError ? <div className="text-warning m-a-2">{queryError}</div> : null}
             <main className="m-t-2">
-              {showingGraph ? (
+              {supportsGraph && showingGraph ? (
                 <Graph
                   data={graphResult}
                   id={`explore-graph-${position}`}
@@ -404,7 +473,8 @@ export class Explore extends React.Component<any, IExploreState> {
                   split={split}
                 />
               ) : null}
-              {showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
+              {supportsTable && showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
+              {supportsLogs && showingLogs ? <Logs data={logsResult} /> : null}
             </main>
           </div>
         ) : null}

+ 9 - 0
public/app/containers/Explore/JSONViewer.tsx

@@ -0,0 +1,9 @@
+import React from 'react';
+
+export default function({ value }) {
+  return (
+    <div>
+      <pre>{JSON.stringify(value, undefined, 2)}</pre>
+    </div>
+  );
+}

+ 66 - 0
public/app/containers/Explore/Logs.tsx

@@ -0,0 +1,66 @@
+import React, { Fragment, PureComponent } from 'react';
+
+import { LogsModel, LogRow } from 'app/core/logs_model';
+
+interface LogsProps {
+  className?: string;
+  data: LogsModel;
+}
+
+const EXAMPLE_QUERY = '{job="default/prometheus"}';
+
+const Entry: React.SFC<LogRow> = props => {
+  const { entry, searchMatches } = props;
+  if (searchMatches && searchMatches.length > 0) {
+    let lastMatchEnd = 0;
+    const spans = searchMatches.reduce((acc, match, i) => {
+      // Insert non-match
+      if (match.start !== lastMatchEnd) {
+        acc.push(<>{entry.slice(lastMatchEnd, match.start)}</>);
+      }
+      // Match
+      acc.push(
+        <span className="logs-row-match-highlight" title={`Matching expression: ${match.text}`}>
+          {entry.substr(match.start, match.length)}
+        </span>
+      );
+      lastMatchEnd = match.start + match.length;
+      // Non-matching end
+      if (i === searchMatches.length - 1) {
+        acc.push(<>{entry.slice(lastMatchEnd)}</>);
+      }
+      return acc;
+    }, []);
+    return <>{spans}</>;
+  }
+  return <>{props.entry}</>;
+};
+
+export default class Logs extends PureComponent<LogsProps, any> {
+  render() {
+    const { className = '', data } = this.props;
+    const hasData = data && data.rows && data.rows.length > 0;
+    return (
+      <div className={`${className} logs`}>
+        {hasData ? (
+          <div className="logs-entries panel-container">
+            {data.rows.map(row => (
+              <Fragment key={row.key}>
+                <div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
+                <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
+                <div>
+                  <Entry {...row} />
+                </div>
+              </Fragment>
+            ))}
+          </div>
+        ) : null}
+        {!hasData ? (
+          <div className="panel-container">
+            Enter a query like <code>{EXAMPLE_QUERY}</code>
+          </div>
+        ) : null}
+      </div>
+    );
+  }
+}

+ 1 - 0
public/app/containers/Explore/QueryField.tsx

@@ -417,6 +417,7 @@ class QueryField extends React.Component<any, any> {
     const url = `/api/v1/label/${key}/values`;
     try {
       const res = await this.request(url);
+      console.log(res);
       const body = await (res.data || res.json());
       const pairs = this.state.labelValues[EMPTY_METRIC];
       const values = {

+ 29 - 0
public/app/core/logs_model.ts

@@ -0,0 +1,29 @@
+export enum LogLevel {
+  crit = 'crit',
+  warn = 'warn',
+  err = 'error',
+  error = 'error',
+  info = 'info',
+  debug = 'debug',
+  trace = 'trace',
+}
+
+export interface LogSearchMatch {
+  start: number;
+  length: number;
+  text?: string;
+}
+
+export interface LogRow {
+  key: string;
+  entry: string;
+  logLevel: LogLevel;
+  timestamp: string;
+  timeFromNow: string;
+  timeLocal: string;
+  searchMatches?: LogSearchMatch[];
+}
+
+export interface LogsModel {
+  rows: LogRow[];
+}

+ 2 - 0
public/app/features/plugins/built_in_plugins.ts

@@ -4,6 +4,7 @@ import * as elasticsearchPlugin from 'app/plugins/datasource/elasticsearch/modul
 import * as opentsdbPlugin from 'app/plugins/datasource/opentsdb/module';
 import * as grafanaPlugin from 'app/plugins/datasource/grafana/module';
 import * as influxdbPlugin from 'app/plugins/datasource/influxdb/module';
+import * as loggingPlugin from 'app/plugins/datasource/logging/module';
 import * as mixedPlugin from 'app/plugins/datasource/mixed/module';
 import * as mysqlPlugin from 'app/plugins/datasource/mysql/module';
 import * as postgresPlugin from 'app/plugins/datasource/postgres/module';
@@ -28,6 +29,7 @@ const builtInPlugins = {
   'app/plugins/datasource/opentsdb/module': opentsdbPlugin,
   'app/plugins/datasource/grafana/module': grafanaPlugin,
   'app/plugins/datasource/influxdb/module': influxdbPlugin,
+  'app/plugins/datasource/logging/module': loggingPlugin,
   'app/plugins/datasource/mixed/module': mixedPlugin,
   'app/plugins/datasource/mysql/module': mysqlPlugin,
   'app/plugins/datasource/postgres/module': postgresPlugin,

+ 3 - 0
public/app/plugins/datasource/logging/README.md

@@ -0,0 +1,3 @@
+# Grafana Logging Datasource -  Native Plugin
+
+This is a **built in** datasource that allows you to connect to Grafana's logging service.

+ 38 - 0
public/app/plugins/datasource/logging/datasource.jest.ts

@@ -0,0 +1,38 @@
+import { parseQuery } from './datasource';
+
+describe('parseQuery', () => {
+  it('returns empty for empty string', () => {
+    expect(parseQuery('')).toEqual({
+      query: '',
+      regexp: '',
+    });
+  });
+
+  it('returns regexp for strings without query', () => {
+    expect(parseQuery('test')).toEqual({
+      query: '',
+      regexp: 'test',
+    });
+  });
+
+  it('returns query for strings without regexp', () => {
+    expect(parseQuery('{foo="bar"}')).toEqual({
+      query: '{foo="bar"}',
+      regexp: '',
+    });
+  });
+
+  it('returns query for strings with query and search string', () => {
+    expect(parseQuery('x {foo="bar"}')).toEqual({
+      query: '{foo="bar"}',
+      regexp: 'x',
+    });
+  });
+
+  it('returns query for strings with query and regexp', () => {
+    expect(parseQuery('{foo="bar"} x|y')).toEqual({
+      query: '{foo="bar"}',
+      regexp: 'x|y',
+    });
+  });
+});

+ 134 - 0
public/app/plugins/datasource/logging/datasource.ts

@@ -0,0 +1,134 @@
+import _ from 'lodash';
+
+import * as dateMath from 'app/core/utils/datemath';
+
+import { processStreams } from './result_transformer';
+
+const DEFAULT_LIMIT = 100;
+
+const DEFAULT_QUERY_PARAMS = {
+  direction: 'BACKWARD',
+  limit: DEFAULT_LIMIT,
+  regexp: '',
+  query: '',
+};
+
+const QUERY_REGEXP = /({\w+="[^"]+"})?\s*(\w[^{]+)?\s*({\w+="[^"]+"})?/;
+export function parseQuery(input: string) {
+  const match = input.match(QUERY_REGEXP);
+  let query = '';
+  let regexp = '';
+
+  if (match) {
+    if (match[1]) {
+      query = match[1];
+    }
+    if (match[2]) {
+      regexp = match[2].trim();
+    }
+    if (match[3]) {
+      if (match[1]) {
+        query = `${match[1].slice(0, -1)},${match[3].slice(1)}`;
+      } else {
+        query = match[3];
+      }
+    }
+  }
+
+  return { query, regexp };
+}
+
+function serializeParams(data: any) {
+  return Object.keys(data)
+    .map(k => {
+      const v = data[k];
+      return encodeURIComponent(k) + '=' + encodeURIComponent(v);
+    })
+    .join('&');
+}
+
+export default class LoggingDatasource {
+  /** @ngInject */
+  constructor(private instanceSettings, private backendSrv, private templateSrv) {}
+
+  _request(apiUrl: string, data?, options?: any) {
+    const baseUrl = this.instanceSettings.url;
+    const params = data ? serializeParams(data) : '';
+    const url = `${baseUrl}${apiUrl}?${params}`;
+    const req = {
+      ...options,
+      url,
+    };
+    return this.backendSrv.datasourceRequest(req);
+  }
+
+  prepareQueryTarget(target, options) {
+    const interpolated = this.templateSrv.replace(target.expr);
+    const start = this.getTime(options.range.from, false);
+    const end = this.getTime(options.range.to, true);
+    return {
+      ...DEFAULT_QUERY_PARAMS,
+      ...parseQuery(interpolated),
+      start,
+      end,
+    };
+  }
+
+  query(options) {
+    const queryTargets = options.targets
+      .filter(target => target.expr)
+      .map(target => this.prepareQueryTarget(target, options));
+    if (queryTargets.length === 0) {
+      return Promise.resolve({ data: [] });
+    }
+
+    const queries = queryTargets.map(target => this._request('/api/prom/query', target));
+
+    return Promise.all(queries).then((results: any[]) => {
+      // Flatten streams from multiple queries
+      const allStreams = results.reduce((acc, response, i) => {
+        const streams = response.data.streams || [];
+        // Inject search for match highlighting
+        const search = queryTargets[i].regexp;
+        streams.forEach(s => {
+          s.search = search;
+        });
+        return [...acc, ...streams];
+      }, []);
+      const model = processStreams(allStreams, DEFAULT_LIMIT);
+      return { data: model };
+    });
+  }
+
+  metadataRequest(url) {
+    // HACK to get label values for {job=|}, will be replaced when implementing LoggingQueryField
+    const apiUrl = url.replace('v1', 'prom');
+    return this._request(apiUrl, { silent: true }).then(res => {
+      const data = { data: { data: res.data.values || [] } };
+      return data;
+    });
+  }
+
+  getTime(date, roundUp) {
+    if (_.isString(date)) {
+      date = dateMath.parse(date, roundUp);
+    }
+    return Math.ceil(date.valueOf() * 1e6);
+  }
+
+  testDatasource() {
+    return this._request('/api/prom/label')
+      .then(res => {
+        if (res && res.data && res.data.values && res.data.values.length > 0) {
+          return { status: 'success', message: 'Data source connected and labels found.' };
+        }
+        return {
+          status: 'error',
+          message: 'Data source connected, but no labels received. Verify that logging is configured properly.',
+        };
+      })
+      .catch(err => {
+        return { status: 'error', message: err.message };
+      });
+  }
+}

+ 57 - 0
public/app/plugins/datasource/logging/img/grafana_icon.svg

@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 20.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="351px" height="365px" viewBox="0 0 351 365" style="enable-background:new 0 0 351 365;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:url(#SVGID_1_);}
+</style>
+<g id="Layer_1_1_">
+</g>
+<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="175.5" y1="445.4948" x2="175.5" y2="114.0346">
+	<stop  offset="0" style="stop-color:#FFF100"/>
+	<stop  offset="1" style="stop-color:#F05A28"/>
+</linearGradient>
+<path class="st0" d="M342,161.2c-0.6-6.1-1.6-13.1-3.6-20.9c-2-7.7-5-16.2-9.4-25c-4.4-8.8-10.1-17.9-17.5-26.8
+	c-2.9-3.5-6.1-6.9-9.5-10.2c5.1-20.3-6.2-37.9-6.2-37.9c-19.5-1.2-31.9,6.1-36.5,9.4c-0.8-0.3-1.5-0.7-2.3-1
+	c-3.3-1.3-6.7-2.6-10.3-3.7c-3.5-1.1-7.1-2.1-10.8-3c-3.7-0.9-7.4-1.6-11.2-2.2c-0.7-0.1-1.3-0.2-2-0.3
+	c-8.5-27.2-32.9-38.6-32.9-38.6c-27.3,17.3-32.4,41.5-32.4,41.5s-0.1,0.5-0.3,1.4c-1.5,0.4-3,0.9-4.5,1.3c-2.1,0.6-4.2,1.4-6.2,2.2
+	c-2.1,0.8-4.1,1.6-6.2,2.5c-4.1,1.8-8.2,3.8-12.2,6c-3.9,2.2-7.7,4.6-11.4,7.1c-0.5-0.2-1-0.4-1-0.4c-37.8-14.4-71.3,2.9-71.3,2.9
+	c-3.1,40.2,15.1,65.5,18.7,70.1c-0.9,2.5-1.7,5-2.5,7.5c-2.8,9.1-4.9,18.4-6.2,28.1c-0.2,1.4-0.4,2.8-0.5,4.2
+	C18.8,192.7,8.5,228,8.5,228c29.1,33.5,63.1,35.6,63.1,35.6c0,0,0.1-0.1,0.1-0.1c4.3,7.7,9.3,15,14.9,21.9c2.4,2.9,4.8,5.6,7.4,8.3
+	c-10.6,30.4,1.5,55.6,1.5,55.6c32.4,1.2,53.7-14.2,58.2-17.7c3.2,1.1,6.5,2.1,9.8,2.9c10,2.6,20.2,4.1,30.4,4.5
+	c2.5,0.1,5.1,0.2,7.6,0.1l1.2,0l0.8,0l1.6,0l1.6-0.1l0,0.1c15.3,21.8,42.1,24.9,42.1,24.9c19.1-20.1,20.2-40.1,20.2-44.4l0,0
+	c0,0,0-0.1,0-0.3c0-0.4,0-0.6,0-0.6l0,0c0-0.3,0-0.6,0-0.9c4-2.8,7.8-5.8,11.4-9.1c7.6-6.9,14.3-14.8,19.9-23.3
+	c0.5-0.8,1-1.6,1.5-2.4c21.6,1.2,36.9-13.4,36.9-13.4c-3.6-22.5-16.4-33.5-19.1-35.6l0,0c0,0-0.1-0.1-0.3-0.2
+	c-0.2-0.1-0.2-0.2-0.2-0.2c0,0,0,0,0,0c-0.1-0.1-0.3-0.2-0.5-0.3c0.1-1.4,0.2-2.7,0.3-4.1c0.2-2.4,0.2-4.9,0.2-7.3l0-1.8l0-0.9
+	l0-0.5c0-0.6,0-0.4,0-0.6l-0.1-1.5l-0.1-2c0-0.7-0.1-1.3-0.2-1.9c-0.1-0.6-0.1-1.3-0.2-1.9l-0.2-1.9l-0.3-1.9
+	c-0.4-2.5-0.8-4.9-1.4-7.4c-2.3-9.7-6.1-18.9-11-27.2c-5-8.3-11.2-15.6-18.3-21.8c-7-6.2-14.9-11.2-23.1-14.9
+	c-8.3-3.7-16.9-6.1-25.5-7.2c-4.3-0.6-8.6-0.8-12.9-0.7l-1.6,0l-0.4,0c-0.1,0-0.6,0-0.5,0l-0.7,0l-1.6,0.1c-0.6,0-1.2,0.1-1.7,0.1
+	c-2.2,0.2-4.4,0.5-6.5,0.9c-8.6,1.6-16.7,4.7-23.8,9c-7.1,4.3-13.3,9.6-18.3,15.6c-5,6-8.9,12.7-11.6,19.6c-2.7,6.9-4.2,14.1-4.6,21
+	c-0.1,1.7-0.1,3.5-0.1,5.2c0,0.4,0,0.9,0,1.3l0.1,1.4c0.1,0.8,0.1,1.7,0.2,2.5c0.3,3.5,1,6.9,1.9,10.1c1.9,6.5,4.9,12.4,8.6,17.4
+	c3.7,5,8.2,9.1,12.9,12.4c4.7,3.2,9.8,5.5,14.8,7c5,1.5,10,2.1,14.7,2.1c0.6,0,1.2,0,1.7,0c0.3,0,0.6,0,0.9,0c0.3,0,0.6,0,0.9-0.1
+	c0.5,0,1-0.1,1.5-0.1c0.1,0,0.3,0,0.4-0.1l0.5-0.1c0.3,0,0.6-0.1,0.9-0.1c0.6-0.1,1.1-0.2,1.7-0.3c0.6-0.1,1.1-0.2,1.6-0.4
+	c1.1-0.2,2.1-0.6,3.1-0.9c2-0.7,4-1.5,5.7-2.4c1.8-0.9,3.4-2,5-3c0.4-0.3,0.9-0.6,1.3-1c1.6-1.3,1.9-3.7,0.6-5.3
+	c-1.1-1.4-3.1-1.8-4.7-0.9c-0.4,0.2-0.8,0.4-1.2,0.6c-1.4,0.7-2.8,1.3-4.3,1.8c-1.5,0.5-3.1,0.9-4.7,1.2c-0.8,0.1-1.6,0.2-2.5,0.3
+	c-0.4,0-0.8,0.1-1.3,0.1c-0.4,0-0.9,0-1.2,0c-0.4,0-0.8,0-1.2,0c-0.5,0-1,0-1.5-0.1c0,0-0.3,0-0.1,0l-0.2,0l-0.3,0
+	c-0.2,0-0.5,0-0.7-0.1c-0.5-0.1-0.9-0.1-1.4-0.2c-3.7-0.5-7.4-1.6-10.9-3.2c-3.6-1.6-7-3.8-10.1-6.6c-3.1-2.8-5.8-6.1-7.9-9.9
+	c-2.1-3.8-3.6-8-4.3-12.4c-0.3-2.2-0.5-4.5-0.4-6.7c0-0.6,0.1-1.2,0.1-1.8c0,0.2,0-0.1,0-0.1l0-0.2l0-0.5c0-0.3,0.1-0.6,0.1-0.9
+	c0.1-1.2,0.3-2.4,0.5-3.6c1.7-9.6,6.5-19,13.9-26.1c1.9-1.8,3.9-3.4,6-4.9c2.1-1.5,4.4-2.8,6.8-3.9c2.4-1.1,4.8-2,7.4-2.7
+	c2.5-0.7,5.1-1.1,7.8-1.4c1.3-0.1,2.6-0.2,4-0.2c0.4,0,0.6,0,0.9,0l1.1,0l0.7,0c0.3,0,0,0,0.1,0l0.3,0l1.1,0.1
+	c2.9,0.2,5.7,0.6,8.5,1.3c5.6,1.2,11.1,3.3,16.2,6.1c10.2,5.7,18.9,14.5,24.2,25.1c2.7,5.3,4.6,11,5.5,16.9c0.2,1.5,0.4,3,0.5,4.5
+	l0.1,1.1l0.1,1.1c0,0.4,0,0.8,0,1.1c0,0.4,0,0.8,0,1.1l0,1l0,1.1c0,0.7-0.1,1.9-0.1,2.6c-0.1,1.6-0.3,3.3-0.5,4.9
+	c-0.2,1.6-0.5,3.2-0.8,4.8c-0.3,1.6-0.7,3.2-1.1,4.7c-0.8,3.1-1.8,6.2-3,9.3c-2.4,6-5.6,11.8-9.4,17.1
+	c-7.7,10.6-18.2,19.2-30.2,24.7c-6,2.7-12.3,4.7-18.8,5.7c-3.2,0.6-6.5,0.9-9.8,1l-0.6,0l-0.5,0l-1.1,0l-1.6,0l-0.8,0
+	c0.4,0-0.1,0-0.1,0l-0.3,0c-1.8,0-3.5-0.1-5.3-0.3c-7-0.5-13.9-1.8-20.7-3.7c-6.7-1.9-13.2-4.6-19.4-7.8
+	c-12.3-6.6-23.4-15.6-32-26.5c-4.3-5.4-8.1-11.3-11.2-17.4c-3.1-6.1-5.6-12.6-7.4-19.1c-1.8-6.6-2.9-13.3-3.4-20.1l-0.1-1.3l0-0.3
+	l0-0.3l0-0.6l0-1.1l0-0.3l0-0.4l0-0.8l0-1.6l0-0.3c0,0,0,0.1,0-0.1l0-0.6c0-0.8,0-1.7,0-2.5c0.1-3.3,0.4-6.8,0.8-10.2
+	c0.4-3.4,1-6.9,1.7-10.3c0.7-3.4,1.5-6.8,2.5-10.2c1.9-6.7,4.3-13.2,7.1-19.3c5.7-12.2,13.1-23.1,22-31.8c2.2-2.2,4.5-4.2,6.9-6.2
+	c2.4-1.9,4.9-3.7,7.5-5.4c2.5-1.7,5.2-3.2,7.9-4.6c1.3-0.7,2.7-1.4,4.1-2c0.7-0.3,1.4-0.6,2.1-0.9c0.7-0.3,1.4-0.6,2.1-0.9
+	c2.8-1.2,5.7-2.2,8.7-3.1c0.7-0.2,1.5-0.4,2.2-0.7c0.7-0.2,1.5-0.4,2.2-0.6c1.5-0.4,3-0.8,4.5-1.1c0.7-0.2,1.5-0.3,2.3-0.5
+	c0.8-0.2,1.5-0.3,2.3-0.5c0.8-0.1,1.5-0.3,2.3-0.4l1.1-0.2l1.2-0.2c0.8-0.1,1.5-0.2,2.3-0.3c0.9-0.1,1.7-0.2,2.6-0.3
+	c0.7-0.1,1.9-0.2,2.6-0.3c0.5-0.1,1.1-0.1,1.6-0.2l1.1-0.1l0.5-0.1l0.6,0c0.9-0.1,1.7-0.1,2.6-0.2l1.3-0.1c0,0,0.5,0,0.1,0l0.3,0
+	l0.6,0c0.7,0,1.5-0.1,2.2-0.1c2.9-0.1,5.9-0.1,8.8,0c5.8,0.2,11.5,0.9,17,1.9c11.1,2.1,21.5,5.6,31,10.3
+	c9.5,4.6,17.9,10.3,25.3,16.5c0.5,0.4,0.9,0.8,1.4,1.2c0.4,0.4,0.9,0.8,1.3,1.2c0.9,0.8,1.7,1.6,2.6,2.4c0.9,0.8,1.7,1.6,2.5,2.4
+	c0.8,0.8,1.6,1.6,2.4,2.5c3.1,3.3,6,6.6,8.6,10c5.2,6.7,9.4,13.5,12.7,19.9c0.2,0.4,0.4,0.8,0.6,1.2c0.2,0.4,0.4,0.8,0.6,1.2
+	c0.4,0.8,0.8,1.6,1.1,2.4c0.4,0.8,0.7,1.5,1.1,2.3c0.3,0.8,0.7,1.5,1,2.3c1.2,3,2.4,5.9,3.3,8.6c1.5,4.4,2.6,8.3,3.5,11.7
+	c0.3,1.4,1.6,2.3,3,2.1c1.5-0.1,2.6-1.3,2.6-2.8C342.6,170.4,342.5,166.1,342,161.2z"/>
+</svg>

+ 7 - 0
public/app/plugins/datasource/logging/module.ts

@@ -0,0 +1,7 @@
+import Datasource from './datasource';
+
+export class LoggingConfigCtrl {
+  static templateUrl = 'partials/config.html';
+}
+
+export { Datasource, LoggingConfigCtrl as ConfigCtrl };

+ 2 - 0
public/app/plugins/datasource/logging/partials/config.html

@@ -0,0 +1,2 @@
+<datasource-http-settings current="ctrl.current" no-direct-access="true">
+</datasource-http-settings>

+ 28 - 0
public/app/plugins/datasource/logging/plugin.json

@@ -0,0 +1,28 @@
+{
+  "type": "datasource",
+  "name": "Grafana Logging",
+  "id": "logging",
+  "metrics": false,
+  "alerting": false,
+  "annotations": false,
+  "logs": true,
+  "explore": true,
+  "info": {
+    "description": "Grafana Logging Data Source for Grafana",
+    "author": {
+      "name": "Grafana Project",
+      "url": "https://grafana.com"
+    },
+    "logos": {
+      "small": "img/grafana_icon.svg",
+      "large": "img/grafana_icon.svg"
+    },
+    "links": [
+      {
+        "name": "Grafana Logging",
+        "url": "https://grafana.com/"
+      }
+    ],
+    "version": "5.3.0"
+  }
+}

+ 45 - 0
public/app/plugins/datasource/logging/result_transformer.jest.ts

@@ -0,0 +1,45 @@
+import { LogLevel } from 'app/core/logs_model';
+
+import { getLogLevel, getSearchMatches } from './result_transformer';
+
+describe('getSearchMatches()', () => {
+  it('gets no matches for when search and or line are empty', () => {
+    expect(getSearchMatches('', '')).toEqual([]);
+    expect(getSearchMatches('foo', '')).toEqual([]);
+    expect(getSearchMatches('', 'foo')).toEqual([]);
+  });
+
+  it('gets no matches for unmatched search string', () => {
+    expect(getSearchMatches('foo', 'bar')).toEqual([]);
+  });
+
+  it('gets matches for matched search string', () => {
+    expect(getSearchMatches('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo' }]);
+    expect(getSearchMatches(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo' }]);
+  });
+
+  expect(getSearchMatches(' foo foo bar ', 'foo|bar')).toEqual([
+    { length: 3, start: 1, text: 'foo' },
+    { length: 3, start: 5, text: 'foo' },
+    { length: 3, start: 9, text: 'bar' },
+  ]);
+});
+
+describe('getLoglevel()', () => {
+  it('returns no log level on empty line', () => {
+    expect(getLogLevel('')).toBe(undefined);
+  });
+
+  it('returns no log level on when level is part of a word', () => {
+    expect(getLogLevel('this is a warning')).toBe(undefined);
+  });
+
+  it('returns log level on line contains a log level', () => {
+    expect(getLogLevel('warn: it is looking bad')).toBe(LogLevel.warn);
+    expect(getLogLevel('2007-12-12 12:12:12 [WARN]: it is looking bad')).toBe(LogLevel.warn);
+  });
+
+  it('returns first log level found', () => {
+    expect(getLogLevel('WARN this could be a debug message')).toBe(LogLevel.warn);
+  });
+});

+ 71 - 0
public/app/plugins/datasource/logging/result_transformer.ts

@@ -0,0 +1,71 @@
+import _ from 'lodash';
+import moment from 'moment';
+
+import { LogLevel, LogsModel, LogRow } from 'app/core/logs_model';
+
+export function getLogLevel(line: string): LogLevel {
+  if (!line) {
+    return undefined;
+  }
+  let level: LogLevel;
+  Object.keys(LogLevel).forEach(key => {
+    if (!level) {
+      const regexp = new RegExp(`\\b${key}\\b`, 'i');
+      if (regexp.test(line)) {
+        level = LogLevel[key];
+      }
+    }
+  });
+  return level;
+}
+
+export function getSearchMatches(line: string, search: string) {
+  // Empty search can send re.exec() into infinite loop, exit early
+  if (!line || !search) {
+    return [];
+  }
+  const regexp = new RegExp(`(?:${search})`, 'g');
+  const matches = [];
+  let match;
+  while ((match = regexp.exec(line))) {
+    matches.push({
+      text: match[0],
+      start: match.index,
+      length: match[0].length,
+    });
+  }
+  return matches;
+}
+
+export function processEntry(entry: { line: string; timestamp: string }, stream): LogRow {
+  const { line, timestamp } = entry;
+  const { labels } = stream;
+  const key = `EK${timestamp}${labels}`;
+  const time = moment(timestamp);
+  const timeFromNow = time.fromNow();
+  const timeLocal = time.format('YYYY-MM-DD HH:mm:ss');
+  const searchMatches = getSearchMatches(line, stream.search);
+  const logLevel = getLogLevel(line);
+
+  return {
+    key,
+    logLevel,
+    searchMatches,
+    timeFromNow,
+    timeLocal,
+    entry: line,
+    timestamp: timestamp,
+  };
+}
+
+export function processStreams(streams, limit?: number): LogsModel {
+  const combinedEntries = streams.reduce((acc, stream) => {
+    return [...acc, ...stream.entries.map(entry => processEntry(entry, stream))];
+  }, []);
+  const sortedEntries = _.chain(combinedEntries)
+    .sortBy('timestamp')
+    .reverse()
+    .slice(0, limit || combinedEntries.length)
+    .value();
+  return { rows: sortedEntries };
+}

+ 37 - 0
public/sass/pages/_explore.scss

@@ -97,3 +97,40 @@
 .query-row-tools {
   width: 4rem;
 }
+
+.explore {
+  .logs {
+    .logs-entries {
+      display: grid;
+      grid-column-gap: 1rem;
+      grid-row-gap: 0.1rem;
+      grid-template-columns: 4px minmax(100px, max-content) 1fr;
+      font-family: $font-family-monospace;
+    }
+
+    .logs-row-match-highlight {
+      background-color: lighten($blue, 20%);
+    }
+
+    .logs-row-level {
+      background-color: transparent;
+      margin: 6px 0;
+      border-radius: 2px;
+      opacity: 0.8;
+    }
+
+    .logs-row-level-crit,
+    .logs-row-level-error,
+    .logs-row-level-err {
+      background-color: $red;
+    }
+
+    .logs-row-level-warn {
+      background-color: $orange;
+    }
+
+    .logs-row-level-info {
+      background-color: $green;
+    }
+  }
+}